fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -136,7 +136,7 @@ impl RpcHandler {
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// response instead of intermittent failures.
|
||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
pub(in crate::api::rpc) async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
|
||||
@@ -73,6 +73,34 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve known purchases BEFORE any mint/spend. Missing bytes or an unreadable
|
||||
// index require recovery; neither is authorization to charge the buyer again.
|
||||
async fn existing_paid_content(
|
||||
data_dir: &std::path::Path,
|
||||
onion: &str,
|
||||
content_id: &str,
|
||||
filename: Option<&str>,
|
||||
) -> Result<Option<serde_json::Value>> {
|
||||
let owned = crate::content_owned::list_owned_checked(data_dir)
|
||||
.await
|
||||
.context("Could not verify previous purchases; no new payment was sent")?;
|
||||
let Some(item) = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty() && o.filename.trim_start_matches('/') == f.trim_start_matches('/')
|
||||
}))
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (mime, bytes) = crate::content_owned::read_owned(data_dir, &item.onion, &item.content_id)
|
||||
.await.context("This purchase is recorded, but its cached file is unavailable. No new payment was sent. Restore the cached file or contact the seller.")?;
|
||||
let mut response = paid_content_response(&bytes, &mime, 0);
|
||||
response["already_owned"] = serde_json::json!(true);
|
||||
response["filename"] = serde_json::json!(item.filename);
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
// Updated clients open the persisted file through the Range-capable HTTP
|
||||
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
|
||||
// Keep older clients compatible until both sides have upgraded.
|
||||
@@ -517,36 +545,15 @@ impl RpcHandler {
|
||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||
// catches duplicate ids pointing at the same file on the same
|
||||
// seller. The owned copy is served from the local cache instead.
|
||||
if let Some(cached) = existing_paid_content(
|
||||
&self.config.data_dir,
|
||||
onion,
|
||||
content_id,
|
||||
params.get("filename").and_then(|v| v.as_str()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
let already = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
tracing::info!(
|
||||
onion,
|
||||
content_id,
|
||||
owned_as = %o.content_id,
|
||||
"paid download: already owned — serving cached copy, NOT paying again"
|
||||
);
|
||||
if let Some((mime, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||
result["already_owned"] = serde_json::json!(true);
|
||||
result["filename"] = serde_json::json!(o.filename);
|
||||
return Ok(result);
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
}
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
|
||||
@@ -71,3 +71,68 @@ fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavailable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
crate::content_owned::record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"id",
|
||||
"file.txt",
|
||||
"text/plain",
|
||||
b"paid",
|
||||
1,
|
||||
"cashu",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (id, filename) in [("id", None), ("duplicate-id", Some("/file.txt"))] {
|
||||
let cached = existing_paid_content(dir.path(), "seller.onion", id, filename)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cached["paid_sats"], 0);
|
||||
assert_eq!(cached["already_owned"], true);
|
||||
assert_eq!(cached["data"], "cGFpZA==");
|
||||
}
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "different.onion", "id", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
tokio::fs::remove_file(dir.path().join("purchased-content/seller.onion/id"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No new payment")
|
||||
);
|
||||
tokio::fs::write(dir.path().join("purchased-content/owned.json"), b"damaged")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "other-id", None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no new payment")
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("purchased-content/owned.json"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"damaged"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ impl RpcHandler {
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.bump-quote" => self.handle_lnd_bump_quote(params).await,
|
||||
"lnd.bump-submit" => self.handle_lnd_bump_submit(params).await,
|
||||
"lnd.bump-status" => self.handle_lnd_bump_status(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
|
||||
@@ -272,28 +272,8 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
peer = pubkey,
|
||||
@@ -574,7 +554,7 @@ impl RpcHandler {
|
||||
|
||||
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
|
||||
/// With neither parameter LND uses a lax target; keep legacy clients on our
|
||||
/// explicit Standard target rather than silently accepting that default.
|
||||
/// explicit next-block target rather than silently accepting that default.
|
||||
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
|
||||
let force = match params.get("force") {
|
||||
None | Some(serde_json::Value::Null) => false,
|
||||
@@ -609,7 +589,12 @@ fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static s
|
||||
if let Some(rate) = rate {
|
||||
query.push(("sat_per_vbyte", rate.to_string()));
|
||||
} else {
|
||||
query.push(("target_conf", target.unwrap_or(6).to_string()));
|
||||
query.push((
|
||||
"target_conf",
|
||||
target
|
||||
.unwrap_or(super::fee_policy::DEFAULT_TARGET as u64)
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(query)
|
||||
@@ -645,7 +630,7 @@ mod close_fee_tests {
|
||||
}
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({})).unwrap(),
|
||||
vec![("force", "false".into()), ("target_conf", "6".into())]
|
||||
vec![("force", "false".into()), ("target_conf", "1".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
//! WalletKit BumpFee is CPFP for new wallet outputs, RBF only for sweeper inputs.
|
||||
//! Never feed an ordinary payment input to it and call that a replacement.
|
||||
use super::LND_REST_BASE_URL;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{collections::HashMap, path::Path, sync::LazyLock};
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
|
||||
static QUOTES: LazyLock<Mutex<HashMap<String, Quote>>> = LazyLock::new(Default::default);
|
||||
// Serialize check/register/persist across dashboard clients. The create_new receipt
|
||||
// additionally survives process restarts and prevents retries of ambiguous results.
|
||||
static SUBMIT: Mutex<()> = Mutex::const_new(());
|
||||
const QUOTE_SECONDS: u64 = 60;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct Plan {
|
||||
txid: String,
|
||||
method: String,
|
||||
input_txid: String,
|
||||
input_index: u32,
|
||||
parent_txid: String,
|
||||
recipient_sats: u64,
|
||||
rate_sat_vb: u64,
|
||||
current_fee_sats: u64,
|
||||
additional_fee_sats: u64,
|
||||
total_fee_sats: u64,
|
||||
budget_sats: u64,
|
||||
input_sats: u64,
|
||||
parent_vsize: u64,
|
||||
sweep_vsize_bound: u64,
|
||||
tip: String,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Quote {
|
||||
quote_id: String,
|
||||
expires_at: u64,
|
||||
custom_rate: Option<u64>,
|
||||
#[serde(flatten)]
|
||||
plan: Plan,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Operation {
|
||||
quote: Quote,
|
||||
status: String,
|
||||
message: String,
|
||||
}
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
fn number(v: &Value) -> Result<u64> {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
.context("Missing or invalid wallet amount")
|
||||
}
|
||||
fn txid_param(p: &Value) -> Result<String> {
|
||||
let s = p["txid"].as_str().context("Missing transaction ID")?;
|
||||
ensure!(
|
||||
s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()),
|
||||
"Invalid transaction ID"
|
||||
);
|
||||
Ok(s.to_ascii_lowercase())
|
||||
}
|
||||
fn btc_sats(v: &Value) -> Result<u64> {
|
||||
let n = v.as_f64().context("Missing Bitcoin fee")? * 100_000_000.0;
|
||||
ensure!(
|
||||
n.is_finite() && n >= 0.0 && n <= 2_100_000_000_000_000.0,
|
||||
"Invalid Bitcoin fee"
|
||||
);
|
||||
Ok(n.round() as u64)
|
||||
}
|
||||
fn outpoint_matches(v: &Value, txid: &str, index: u32) -> bool {
|
||||
v["txid_str"].as_str() == Some(txid) && v["output_index"].as_u64() == Some(index as u64)
|
||||
}
|
||||
fn array<'a>(v: &'a Value, key: &str) -> Result<&'a Vec<Value>> {
|
||||
v[key]
|
||||
.as_array()
|
||||
.with_context(|| format!("Missing wallet field: {key}"))
|
||||
}
|
||||
fn sweep_size(output: &Value) -> Result<u64> {
|
||||
// One native input, one wallet taproot output, including signature rounding.
|
||||
match output["output_type"].as_str() {
|
||||
Some("SCRIPT_TYPE_WITNESS_V1_TAPROOT") => Ok(112),
|
||||
Some("SCRIPT_TYPE_WITNESS_V0_PUBKEY_HASH") => Ok(123),
|
||||
_ => bail!("This output type is not supported for fee bumping yet"),
|
||||
}
|
||||
}
|
||||
fn fee_budget(
|
||||
rate: u64,
|
||||
parent_size: u64,
|
||||
parent_fee: u64,
|
||||
size: u64,
|
||||
old_fee: u64,
|
||||
relay: u64,
|
||||
input: u64,
|
||||
) -> Result<u64> {
|
||||
ensure!(
|
||||
(1..=5000).contains(&rate),
|
||||
"Fee rate must be a whole number from 1 to 5000 sat/vB"
|
||||
);
|
||||
ensure!(
|
||||
parent_size <= 100_000 && size <= 100_000 && relay <= 5000,
|
||||
"Unsupported package size or relay fee"
|
||||
);
|
||||
let required = rate * (parent_size + size);
|
||||
let mut budget = required.saturating_sub(parent_fee).max(relay * size);
|
||||
if old_fee > 0 {
|
||||
budget = budget.max(old_fee + relay * size + 1);
|
||||
}
|
||||
ensure!(budget > old_fee, "Choose a higher fee rate");
|
||||
// Conservative dust buffer; never attach unrelated wallet inputs to fund fees.
|
||||
ensure!(
|
||||
budget.checked_add(1000).is_some_and(|v| v <= input),
|
||||
"Not enough wallet change for this fee; choose a lower rate"
|
||||
);
|
||||
Ok(budget)
|
||||
}
|
||||
|
||||
async fn lnd(
|
||||
client: &reqwest::Client,
|
||||
macaroon: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let url = format!("{LND_REST_BASE_URL}{path}");
|
||||
let req = match body {
|
||||
Some(v) => client.post(url).json(&v),
|
||||
None => client.get(url),
|
||||
};
|
||||
let response = req
|
||||
.header("Grpc-Metadata-macaroon", macaroon)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let value: Value = response.json().await.context("Invalid LND response")?;
|
||||
ensure!(
|
||||
status.is_success() && value.get("code").is_none(),
|
||||
"{}",
|
||||
value["message"].as_str().unwrap_or("LND request failed")
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_quote(quote: &Quote, fresh: &Plan, timestamp: u64) -> Result<()> {
|
||||
ensure!(
|
||||
quote.expires_at > timestamp && quote.plan == *fresh,
|
||||
"Transaction or fees changed; review a fresh quote"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn bump_body(plan: &Plan) -> Value {
|
||||
json!({"outpoint":{"txid_str":plan.input_txid,"output_index":plan.input_index},
|
||||
"sat_per_vbyte":plan.rate_sat_vb.to_string(), "budget":plan.budget_sats.to_string(),
|
||||
"deadline_delta":1, "immediate":true})
|
||||
}
|
||||
|
||||
async fn reserve(path: &Path, op: &Operation) -> Result<()> {
|
||||
let parent = path.parent().context("Invalid operation path")?;
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.await
|
||||
.context("A bump already exists for this transaction; check its status")?;
|
||||
f.write_all(&serde_json::to_vec(op)?).await?;
|
||||
f.sync_all().await?;
|
||||
// Sync directory entry too: a crash must not make a submitted operation vanish.
|
||||
tokio::fs::File::open(parent).await?.sync_all().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Only a recorded Archy CPFP with one owned input and no external outputs may
|
||||
// be folded into a payment. Labels and a fee-sized delta alone are not evidence.
|
||||
fn fee_child_matches(tx: &Value, plan: &Plan) -> bool {
|
||||
let input = format!("{}:{}", plan.input_txid, plan.input_index);
|
||||
let amount = tx["amount"]
|
||||
.as_i64()
|
||||
.or_else(|| tx["amount"].as_str()?.parse().ok());
|
||||
let fee = number(&tx["total_fees"])
|
||||
.ok()
|
||||
.and_then(|n| i64::try_from(n).ok());
|
||||
tx["tx_hash"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit()))
|
||||
&& amount
|
||||
.zip(fee)
|
||||
.is_some_and(|(amount, fee)| fee > 0 && amount == -fee)
|
||||
&& tx["previous_outpoints"].as_array().is_some_and(|inputs| {
|
||||
inputs.len() == 1
|
||||
&& inputs[0]["outpoint"] == input
|
||||
&& inputs[0]["is_our_output"] == true
|
||||
})
|
||||
&& tx["output_details"].as_array().is_some_and(|outputs| {
|
||||
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
|
||||
})
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn group_fee_bump_history(
|
||||
&self,
|
||||
raw: &[Value],
|
||||
normalized: &mut Vec<Value>,
|
||||
client: &reqwest::Client,
|
||||
) {
|
||||
let mut hidden = std::collections::HashSet::new();
|
||||
for parent in normalized.iter_mut() {
|
||||
if parent["direction"] != "outgoing" {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = parent["tx_hash"].as_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if id.len() != 64 || !id.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
continue;
|
||||
}
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{id}.json"));
|
||||
let Ok(bytes) = tokio::fs::read(path).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(op) = serde_json::from_slice::<Operation>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let plan = &op.quote.plan;
|
||||
if plan.method != "cpfp"
|
||||
|| plan.txid != id
|
||||
|| plan.parent_txid != id
|
||||
|| plan.input_txid != id
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<_> = raw
|
||||
.iter()
|
||||
.filter(|tx| fee_child_matches(tx, plan))
|
||||
.collect();
|
||||
let mut active = Vec::new();
|
||||
for child in &candidates {
|
||||
let child_id = child["tx_hash"].as_str().unwrap();
|
||||
if child["num_confirmations"].as_i64().unwrap_or(0) > 0
|
||||
|| self
|
||||
.bitcoin_rpc_call::<Value>(client, "getmempoolentry", &[json!(child_id)])
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
active.push(*child);
|
||||
}
|
||||
}
|
||||
// Ambiguous or unavailable chain state must not hide wallet history.
|
||||
if active.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let current = active[0];
|
||||
parent["bump_fee_sats"] = json!(number(¤t["total_fees"]).unwrap());
|
||||
parent["fee_bump_txid"] = current["tx_hash"].clone();
|
||||
parent["fee_bump_confirmations"] = current["num_confirmations"].clone();
|
||||
parent["fee_bump_history"] = json!(candidates.iter().map(|child| {
|
||||
let child_id = child["tx_hash"].as_str().unwrap();
|
||||
hidden.insert(child_id.to_owned());
|
||||
json!({"tx_hash":child_id,"fee_sats":number(&child["total_fees"]).unwrap(),
|
||||
"status":if child["tx_hash"] != current["tx_hash"] { "replaced" }
|
||||
else if child["num_confirmations"].as_i64().unwrap_or(0) > 0 { "confirmed" } else { "mempool" }})
|
||||
}).collect::<Vec<_>>());
|
||||
}
|
||||
normalized.retain(|tx| !tx["tx_hash"].as_str().is_some_and(|id| hidden.contains(id)));
|
||||
}
|
||||
|
||||
async fn bump_plan(&self, txid: &str, custom_rate: Option<u64>) -> Result<Plan> {
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
let info = lnd(&client, &macaroon, "/v1/getinfo", None).await?;
|
||||
ensure!(
|
||||
info["synced_to_chain"] == true,
|
||||
"Wait for the wallet to finish syncing"
|
||||
);
|
||||
let version = info["version"]
|
||||
.as_str()
|
||||
.context("LND version is unavailable")?;
|
||||
let mut parts = version.trim_start_matches('v').split('.');
|
||||
let major: u32 = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.context("Invalid LND version")?;
|
||||
let minor: u32 = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.context("Invalid LND version")?;
|
||||
ensure!(
|
||||
major > 0 || minor >= 21,
|
||||
"This fee-bump interface requires LND 0.21 or newer"
|
||||
);
|
||||
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
|
||||
let txs = array(&history, "transactions")?;
|
||||
let tx = txs
|
||||
.iter()
|
||||
.find(|t| t["tx_hash"] == txid)
|
||||
.context("Transaction is not in this wallet")?;
|
||||
ensure!(
|
||||
tx["num_confirmations"].as_i64() == Some(0),
|
||||
"This transaction is no longer pending"
|
||||
);
|
||||
let entry: Value = self
|
||||
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(txid)])
|
||||
.await
|
||||
.context("Transaction is not currently in the node's mempool")?;
|
||||
ensure!(
|
||||
number(&entry["descendantcount"])? == 1,
|
||||
"This transaction already has a child; open the child's Bump options instead"
|
||||
);
|
||||
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
|
||||
let sweeps = array(&pending, "pending_sweeps")?;
|
||||
let published = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/sweeps?verbose=false&start_height=-1",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let is_sweep = published["transaction_ids"]["transaction_ids"]
|
||||
.as_array()
|
||||
.is_some_and(|ids| ids.iter().any(|id| id == txid));
|
||||
let outputs = array(tx, "output_details")?;
|
||||
ensure!(
|
||||
tx["amount"]
|
||||
.as_str()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.or_else(|| tx["amount"].as_i64())
|
||||
.is_some_and(|v| v < 0),
|
||||
"Bump is available for outgoing payments and wallet fee sweeps"
|
||||
);
|
||||
let (
|
||||
method,
|
||||
input_txid,
|
||||
input_index,
|
||||
input_sats,
|
||||
parent_txid,
|
||||
parent_size,
|
||||
parent_fee,
|
||||
old_fee,
|
||||
size,
|
||||
recipient_sats,
|
||||
) = if is_sweep {
|
||||
// Only a simple wallet CPFP sweep is replaceable here. Anchor/HTLC,
|
||||
// batched sweeps and arbitrary signed payments need different previews.
|
||||
let raw: Value = self
|
||||
.bitcoin_rpc_call(&client, "getrawtransaction", &[json!(txid), json!(true)])
|
||||
.await?;
|
||||
let inputs = array(&raw, "vin")?;
|
||||
ensure!(
|
||||
inputs.len() == 1 && outputs.len() == 1 && outputs[0]["is_our_address"] == true,
|
||||
"RBF for batched or channel sweeps is not supported here yet"
|
||||
);
|
||||
let input_txid = inputs[0]["txid"]
|
||||
.as_str()
|
||||
.context("Missing sweep input")?
|
||||
.to_string();
|
||||
let index = u32::try_from(number(&inputs[0]["vout"])?)?;
|
||||
ensure!(
|
||||
sweeps.len() == 1 && outpoint_matches(&sweeps[0]["outpoint"], &input_txid, index),
|
||||
"RBF is unavailable while other wallet sweeps are active"
|
||||
);
|
||||
let parent = txs
|
||||
.iter()
|
||||
.find(|t| t["tx_hash"] == input_txid)
|
||||
.context("Sweep parent is unavailable")?;
|
||||
let parent_output = array(parent, "output_details")?
|
||||
.iter()
|
||||
.find(|o| {
|
||||
number(&o["output_index"]).ok() == Some(index as u64)
|
||||
&& o["is_our_address"] == true
|
||||
})
|
||||
.context("RBF requires a wallet-owned change input")?;
|
||||
let parent_entry: Value = self
|
||||
.bitcoin_rpc_call(&client, "getmempoolentry", &[json!(input_txid)])
|
||||
.await
|
||||
.context("Only unconfirmed CPFP sweep replacements are supported here")?;
|
||||
ensure!(
|
||||
number(&parent_entry["ancestorcount"])? == 1
|
||||
&& number(&parent_entry["descendantcount"])? == 2,
|
||||
"Complex sweep package cannot be quoted safely"
|
||||
);
|
||||
let recipients = recipient_amount(parent)?;
|
||||
(
|
||||
"rbf",
|
||||
input_txid.clone(),
|
||||
index,
|
||||
number(&parent_output["amount"])?,
|
||||
input_txid,
|
||||
number(&parent_entry["vsize"])?,
|
||||
btc_sats(&parent_entry["fees"]["base"])?,
|
||||
btc_sats(&entry["fees"]["base"])?,
|
||||
sweep_size(parent_output)?.max(number(&entry["vsize"])?),
|
||||
recipients,
|
||||
)
|
||||
} else {
|
||||
ensure!(
|
||||
sweeps.is_empty(),
|
||||
"Another wallet sweep is active; wait for it before creating a CPFP bump"
|
||||
);
|
||||
ensure!(
|
||||
number(&entry["ancestorcount"])? == 1,
|
||||
"Fee bumping a chain of unconfirmed payments is not supported yet"
|
||||
);
|
||||
let unspent = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/utxos",
|
||||
Some(json!({"unconfirmed_only":true})),
|
||||
)
|
||||
.await?;
|
||||
let utxos = array(&unspent, "utxos")?;
|
||||
let leases = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/utxos/leases",
|
||||
Some(json!({})),
|
||||
)
|
||||
.await?;
|
||||
let locked = array(&leases, "locked_utxos")?;
|
||||
let output = outputs
|
||||
.iter()
|
||||
.filter(|o| o["is_our_address"] == true && sweep_size(o).is_ok())
|
||||
.filter(|o| {
|
||||
number(&o["output_index"]).ok().is_some_and(|i| {
|
||||
utxos
|
||||
.iter()
|
||||
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
|
||||
&& !locked
|
||||
.iter()
|
||||
.any(|u| outpoint_matches(&u["outpoint"], txid, i as u32))
|
||||
})
|
||||
})
|
||||
.max_by_key(|o| number(&o["amount"]).unwrap_or(0))
|
||||
.context(
|
||||
"RBF is unavailable for this payment. CPFP needs spendable wallet-owned change",
|
||||
)?;
|
||||
let index = u32::try_from(number(&output["output_index"])?)?;
|
||||
let available: Value = self
|
||||
.bitcoin_rpc_call(
|
||||
&client,
|
||||
"gettxout",
|
||||
&[json!(txid), json!(index), json!(true)],
|
||||
)
|
||||
.await?;
|
||||
ensure!(
|
||||
available.is_object()
|
||||
&& number(&available["confirmations"])? == 0
|
||||
&& btc_sats(&available["value"])? == number(&output["amount"])?,
|
||||
"Change is no longer available"
|
||||
);
|
||||
(
|
||||
"cpfp",
|
||||
txid.to_string(),
|
||||
index,
|
||||
number(&output["amount"])?,
|
||||
txid.to_string(),
|
||||
number(&entry["vsize"])?,
|
||||
btc_sats(&entry["fees"]["base"])?,
|
||||
0,
|
||||
sweep_size(output)?,
|
||||
recipient_amount(tx)?,
|
||||
)
|
||||
};
|
||||
let mempool: Value = self
|
||||
.bitcoin_rpc_call(&client, "getmempoolinfo", &[])
|
||||
.await?;
|
||||
let relay = btc_sats(&mempool["incrementalrelayfee"])?
|
||||
.div_ceil(1000)
|
||||
.max(1);
|
||||
let floor = btc_sats(&mempool["mempoolminfee"])?
|
||||
.max(btc_sats(&mempool["minrelaytxfee"])?)
|
||||
.div_ceil(1000)
|
||||
.max(1);
|
||||
let rate = match custom_rate {
|
||||
Some(rate) => {
|
||||
ensure!(
|
||||
rate >= floor,
|
||||
"Custom rate is below the current mempool minimum"
|
||||
);
|
||||
rate
|
||||
}
|
||||
None => {
|
||||
let estimate = lnd(&client, &macaroon, "/v2/wallet/estimatefee/1", None).await?;
|
||||
number(&estimate["sat_per_kw"])?.div_ceil(250).max(floor)
|
||||
}
|
||||
};
|
||||
let budget = fee_budget(
|
||||
rate,
|
||||
parent_size,
|
||||
parent_fee,
|
||||
size,
|
||||
old_fee,
|
||||
relay.max(floor),
|
||||
input_sats,
|
||||
)?;
|
||||
let tip: String = self
|
||||
.bitcoin_rpc_call(&client, "getbestblockhash", &[])
|
||||
.await?;
|
||||
Ok(Plan {
|
||||
txid: txid.to_string(),
|
||||
method: method.into(),
|
||||
input_txid,
|
||||
input_index,
|
||||
parent_txid,
|
||||
recipient_sats,
|
||||
rate_sat_vb: rate,
|
||||
current_fee_sats: parent_fee + old_fee,
|
||||
additional_fee_sats: budget - old_fee,
|
||||
total_fee_sats: parent_fee + budget,
|
||||
budget_sats: budget,
|
||||
input_sats,
|
||||
parent_vsize: parent_size,
|
||||
sweep_vsize_bound: size,
|
||||
tip,
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_bump_quote(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let txid = txid_param(&p)?;
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{txid}.json"));
|
||||
ensure!(
|
||||
!path.try_exists()?,
|
||||
"A bump was already submitted for this transaction. Check its status"
|
||||
);
|
||||
let custom = p
|
||||
.get("sat_per_vbyte")
|
||||
.map(|v| v.as_u64().context("Custom rate must be a whole number"))
|
||||
.transpose()?;
|
||||
if let Some(rate) = custom {
|
||||
ensure!(
|
||||
(1..=5000).contains(&rate),
|
||||
"Custom rate must be 1–5000 sat/vB"
|
||||
);
|
||||
}
|
||||
let plan = self.bump_plan(&txid, custom).await?;
|
||||
let quote = Quote {
|
||||
quote_id: uuid::Uuid::new_v4().to_string(),
|
||||
expires_at: now() + QUOTE_SECONDS,
|
||||
custom_rate: custom,
|
||||
plan,
|
||||
};
|
||||
let mut quotes = QUOTES.lock().await;
|
||||
quotes.retain(|_, q| q.expires_at > now());
|
||||
ensure!(quotes.len() < 128, "Too many fee quotes; try again shortly");
|
||||
quotes.insert(quote.quote_id.clone(), quote.clone());
|
||||
Ok(serde_json::to_value(quote)?)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_bump_submit(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let txid = txid_param(&p)?;
|
||||
let _guard = SUBMIT.lock().await;
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{txid}.json"));
|
||||
if path.try_exists()? {
|
||||
return self
|
||||
.handle_lnd_bump_status(Some(json!({"txid":txid})))
|
||||
.await;
|
||||
}
|
||||
let id = p["quote_id"]
|
||||
.as_str()
|
||||
.context("A reviewed fee quote is required")?;
|
||||
let quote = QUOTES
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.cloned()
|
||||
.context("Quote expired; review the fee again")?;
|
||||
ensure!(
|
||||
quote.plan.txid == txid && quote.expires_at > now(),
|
||||
"Quote expired; review the fee again"
|
||||
);
|
||||
let fresh = self.bump_plan(&txid, quote.custom_rate).await?;
|
||||
validate_quote("e, &fresh, now())?;
|
||||
let op = Operation {
|
||||
quote: quote.clone(),
|
||||
status: "unknown".into(),
|
||||
message: "Submission recorded; checking the wallet. Do not submit another bump.".into(),
|
||||
};
|
||||
reserve(&path, &op).await?;
|
||||
QUOTES.lock().await.remove(id);
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
// At a one-block deadline LND may spend ALL this explicitly previewed
|
||||
// budget. It is always below input value, so no extra funding is requested.
|
||||
let result = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/bumpfee",
|
||||
Some(bump_body(&fresh)),
|
||||
)
|
||||
.await;
|
||||
// Keep the write-ahead record even for an RPC error: a lost response can
|
||||
// conceal an accepted bump. Status reconciles from wallet/mempool evidence.
|
||||
match result {
|
||||
Ok(_) => Ok(
|
||||
json!({"status":"registered", "message":"Bump registered with the wallet. Waiting for broadcast.", "quote":quote}),
|
||||
),
|
||||
Err(_) => Ok(
|
||||
json!({"status":"unknown", "message":"The wallet response was not confirmed. Check status; do not submit again.", "quote":quote}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_bump_status(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let txid = txid_param(¶ms.unwrap_or_default())?;
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{txid}.json"));
|
||||
let bytes = match tokio::fs::read(path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(json!({"status":"none"}))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let op: Operation = serde_json::from_slice(&bytes)
|
||||
.context("Bump receipt needs recovery; do not resubmit")?;
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
|
||||
let plan = &op.quote.plan;
|
||||
let input = format!("{}:{}", plan.input_txid, plan.input_index);
|
||||
let mut candidates: Vec<&Value> = array(&history, "transactions")?
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t["tx_hash"] != txid
|
||||
&& t["output_details"].as_array().is_some_and(|outputs| {
|
||||
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
|
||||
})
|
||||
&& t["previous_outpoints"]
|
||||
.as_array()
|
||||
.is_some_and(|inputs| inputs.iter().any(|i| i["outpoint"] == input))
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by_key(|t| std::cmp::Reverse(number(&t["time_stamp"]).unwrap_or(0)));
|
||||
for t in candidates {
|
||||
let id = t["tx_hash"]
|
||||
.as_str()
|
||||
.context("Missing bump transaction ID")?;
|
||||
let confirmed = t["num_confirmations"].as_i64().unwrap_or(0) > 0;
|
||||
let accepted = if confirmed {
|
||||
false
|
||||
} else {
|
||||
self.bitcoin_rpc_call::<Value>(&client, "getmempoolentry", &[json!(id)])
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if confirmed || accepted {
|
||||
return Ok(json!({"status":if confirmed {"confirmed"} else {"mempool"},
|
||||
"message":if confirmed {"Fee bump confirmed."} else {"Fee bump accepted in the node's mempool; awaiting confirmation."},
|
||||
"bump_txid":id,"confirmations":t["num_confirmations"],"actual_sweep_fee_sats":number(&t["total_fees"])?,"quote":op.quote}));
|
||||
}
|
||||
}
|
||||
let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?;
|
||||
let registered = array(&pending, "pending_sweeps")?.iter().any(|s| {
|
||||
outpoint_matches(&s["outpoint"], &plan.input_txid, plan.input_index)
|
||||
&& number(&s["budget"]).ok() == Some(plan.budget_sats)
|
||||
&& number(&s["requested_sat_per_vbyte"]).ok() == Some(plan.rate_sat_vb)
|
||||
});
|
||||
Ok(
|
||||
json!({"status":if registered {"registered"} else {"unknown"},
|
||||
"message":if registered {"Bump registered; waiting for a verified broadcast."} else {"Submission outcome is unknown. Do not submit again; check wallet status."}, "quote":op.quote}),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn recipient_amount(tx: &Value) -> Result<u64> {
|
||||
array(tx, "output_details")?
|
||||
.iter()
|
||||
.filter(|o| o["is_our_address"] == false)
|
||||
.try_fold(0u64, |sum, o| {
|
||||
sum.checked_add(number(&o["amount"])?)
|
||||
.context("Recipient amount overflow")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample_plan() -> Plan {
|
||||
serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":161650,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn history_requires_owned_simple_fee_only_child() {
|
||||
let plan = sample_plan();
|
||||
let tx = json!({"tx_hash":"b".repeat(64),"amount":"-200","total_fees":"200",
|
||||
"previous_outpoints":[{"outpoint":"a:0","is_our_output":true}],
|
||||
"output_details":[{"is_our_address":true}]});
|
||||
assert!(fee_child_matches(&tx, &plan));
|
||||
for bad in [
|
||||
json!({"amount":"-201"}),
|
||||
json!({"amount":"200"}),
|
||||
json!({"total_fees":"0"}),
|
||||
json!({"previous_outpoints":[{"outpoint":"a:1","is_our_output":true}]}),
|
||||
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":false}]}),
|
||||
json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":true},{"outpoint":"c:0","is_our_output":true}]}),
|
||||
json!({"output_details":[{"is_our_address":false}]}),
|
||||
json!({"output_details":[]}),
|
||||
json!({"tx_hash":"../../invalid"}),
|
||||
] {
|
||||
let mut changed = tx.clone();
|
||||
for (key, value) in bad.as_object().unwrap() {
|
||||
changed[key] = value.clone();
|
||||
}
|
||||
assert!(!fee_child_matches(&changed, &plan), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stale_quotes_cannot_silently_change_approved_fee_or_transaction() {
|
||||
let plan = sample_plan();
|
||||
let q = Quote {
|
||||
quote_id: "q".into(),
|
||||
expires_at: 100,
|
||||
custom_rate: None,
|
||||
plan: plan.clone(),
|
||||
};
|
||||
assert!(validate_quote(&q, &plan, 99).is_ok());
|
||||
assert!(validate_quote(&q, &plan, 100).is_err());
|
||||
let mut changed = plan.clone();
|
||||
changed.budget_sats += 1;
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
changed = plan.clone();
|
||||
changed.input_index += 1;
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
changed = plan.clone();
|
||||
changed.recipient_sats -= 1;
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
changed = plan.clone();
|
||||
changed.tip = "new block".into();
|
||||
assert!(validate_quote(&q, &changed, 99).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn mutation_always_has_explicit_budget_and_does_not_send_a_second_payment() {
|
||||
assert_eq!(
|
||||
bump_body(&sample_plan()),
|
||||
json!({"outpoint":{"txid_str":"a","output_index":0},"sat_per_vbyte":"3","budget":"618","deadline_delta":1,"immediate":true})
|
||||
);
|
||||
let mut rbf = sample_plan();
|
||||
rbf.method = "rbf".into();
|
||||
rbf.txid = "child".into();
|
||||
// RBF uses the already-registered input, not the child's output.
|
||||
assert_eq!(bump_body(&rbf)["outpoint"]["txid_str"], "a");
|
||||
}
|
||||
#[test]
|
||||
fn outpoint_ownership_and_recipient_exclude_wallet_change() {
|
||||
assert!(outpoint_matches(
|
||||
&json!({"txid_str":"a","output_index":2}),
|
||||
"a",
|
||||
2
|
||||
));
|
||||
assert!(!outpoint_matches(
|
||||
&json!({"txid_str":"b","output_index":2}),
|
||||
"a",
|
||||
2
|
||||
));
|
||||
assert!(!outpoint_matches(
|
||||
&json!({"txid_str":"a","output_index":3}),
|
||||
"a",
|
||||
2
|
||||
));
|
||||
assert_eq!(recipient_amount(&json!({"output_details":[{"is_our_address":true,"amount":"21126"},{"is_our_address":false,"amount":"161650"}]})).unwrap(), 161650);
|
||||
assert!(recipient_amount(
|
||||
&json!({"output_details":[{"is_our_address":false,"amount":"bad"}]})
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cpfp_budget_covers_parent_and_preserves_change() {
|
||||
assert_eq!(fee_budget(3, 142, 144, 112, 0, 1, 21126).unwrap(), 618);
|
||||
assert!(fee_budget(5000, 142, 144, 112, 0, 1, 21126).is_err());
|
||||
assert!(fee_budget(0, 142, 144, 112, 0, 1, 21126).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rbf_pays_incremental_relay_cost_and_counts_only_extra_cost() {
|
||||
let fee = fee_budget(3, 142, 144, 112, 650, 1, 21126).unwrap();
|
||||
assert_eq!(fee, 763);
|
||||
assert_eq!(fee - 650, 113);
|
||||
}
|
||||
#[test]
|
||||
fn unsupported_outputs_and_malformed_ids_fail_closed() {
|
||||
assert!(sweep_size(&json!({"output_type":"SCRIPT_TYPE_WITNESS_V0_SCRIPT_HASH"})).is_err());
|
||||
assert!(txid_param(&json!({"txid":"../../file"})).is_err());
|
||||
assert!(number(&json!(-1)).is_err());
|
||||
assert!(btc_sats(&json!(-0.1)).is_err());
|
||||
assert_eq!(btc_sats(&json!(0.00000650)).unwrap(), 650);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn receipt_prevents_duplicate_submission_after_restart() {
|
||||
let dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
|
||||
let path = dir.join("receipt.json");
|
||||
let plan: Plan = serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":1000,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap();
|
||||
let op = Operation {
|
||||
quote: Quote {
|
||||
quote_id: "q".into(),
|
||||
expires_at: now() + 60,
|
||||
custom_rate: None,
|
||||
plan,
|
||||
},
|
||||
status: "unknown".into(),
|
||||
message: "pending".into(),
|
||||
};
|
||||
reserve(&path, &op).await.unwrap();
|
||||
assert!(reserve(&path, &op).await.is_err());
|
||||
let restored: Operation =
|
||||
serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap();
|
||||
assert_eq!(restored.quote.plan.budget_sats, 618);
|
||||
tokio::fs::remove_dir_all(dir).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Explicit on-chain fee choices retain priority; omitted choices target the next block.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(super) const DEFAULT_TARGET: i64 = 1;
|
||||
|
||||
pub(super) fn estimated_sat_per_vbyte(value: &Value) -> Result<u64> {
|
||||
let per_kw = value["sat_per_kw"]
|
||||
.as_u64()
|
||||
.or_else(|| value["sat_per_kw"].as_str().and_then(|s| s.parse().ok()))
|
||||
.context("Next-block fee estimate is unavailable")?;
|
||||
let rate = per_kw.div_ceil(250);
|
||||
ensure!(
|
||||
(1..=5000).contains(&rate),
|
||||
"Next-block fee estimate is outside supported bounds; choose an explicit fee"
|
||||
);
|
||||
Ok(rate)
|
||||
}
|
||||
|
||||
pub(super) fn fee_options(params: &Value) -> Result<(Option<i64>, Option<i64>)> {
|
||||
let integer = |key: &str, max: i64| -> Result<Option<i64>> {
|
||||
match params.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(value) => {
|
||||
let n = value
|
||||
.as_i64()
|
||||
.with_context(|| format!("{key} must be a positive whole number"))?;
|
||||
ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
};
|
||||
let target = integer("target_conf", 1008)?;
|
||||
let rate = integer("sat_per_vbyte", 5000)?;
|
||||
ensure!(
|
||||
target.is_none() || rate.is_none(),
|
||||
"Specify either target_conf or sat_per_vbyte, not both"
|
||||
);
|
||||
Ok((
|
||||
if rate.is_none() {
|
||||
Some(target.unwrap_or(DEFAULT_TARGET))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
rate,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn estimates_round_up_and_missing_or_extreme_estimates_fail_closed() {
|
||||
assert_eq!(
|
||||
estimated_sat_per_vbyte(&json!({"sat_per_kw":"501"})).unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
estimated_sat_per_vbyte(&json!({"sat_per_kw":250})).unwrap(),
|
||||
1
|
||||
);
|
||||
for v in [
|
||||
json!({}),
|
||||
json!({"sat_per_kw":0}),
|
||||
json!({"sat_per_kw":-1}),
|
||||
json!({"sat_per_kw":1250001}),
|
||||
] {
|
||||
assert!(estimated_sat_per_vbyte(&v).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_block_default_preserves_explicit_slower_and_custom_choices() {
|
||||
assert_eq!(fee_options(&json!({})).unwrap(), (Some(1), None));
|
||||
assert_eq!(
|
||||
fee_options(&json!({"target_conf":null})).unwrap(),
|
||||
(Some(1), None)
|
||||
);
|
||||
for target in [1, 3, 6, 144, 1008] {
|
||||
assert_eq!(
|
||||
fee_options(&json!({"target_conf":target})).unwrap(),
|
||||
(Some(target), None)
|
||||
);
|
||||
}
|
||||
for rate in [1, 17, 5000] {
|
||||
assert_eq!(
|
||||
fee_options(&json!({"sat_per_vbyte":rate})).unwrap(),
|
||||
(None, Some(rate))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_explicit_fees_never_silently_become_fast() {
|
||||
for value in [
|
||||
json!(0),
|
||||
json!(-1),
|
||||
json!(1.5),
|
||||
json!("6"),
|
||||
json!(true),
|
||||
json!({}),
|
||||
json!(1009),
|
||||
] {
|
||||
assert!(fee_options(&json!({"target_conf":value})).is_err());
|
||||
}
|
||||
for value in [
|
||||
json!(0),
|
||||
json!(-1),
|
||||
json!(1.5),
|
||||
json!("6"),
|
||||
json!(true),
|
||||
json!(5001),
|
||||
] {
|
||||
assert!(fee_options(&json!({"sat_per_vbyte":value})).is_err());
|
||||
}
|
||||
assert!(fee_options(&json!({"target_conf":1,"sat_per_vbyte":2})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod channels;
|
||||
mod fee_bump;
|
||||
mod fee_policy;
|
||||
mod info;
|
||||
mod macaroons;
|
||||
mod payments;
|
||||
|
||||
@@ -402,6 +402,9 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
self.group_fee_bump_history(raw_txs, &mut transactions, &client)
|
||||
.await;
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
@@ -124,28 +124,8 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
addr = addr,
|
||||
@@ -238,15 +218,12 @@ impl RpcHandler {
|
||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||
return Err(anyhow::anyhow!("Invalid amount"));
|
||||
}
|
||||
let target_conf = params
|
||||
.get("target_conf")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(6);
|
||||
if !(1..=1008).contains(&target_conf) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
let (target_conf, custom_rate) = super::fee_policy::fee_options(¶ms)?;
|
||||
anyhow::ensure!(
|
||||
custom_rate.is_none(),
|
||||
"Fee estimation requires a confirmation target"
|
||||
);
|
||||
let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
@@ -782,10 +759,24 @@ impl RpcHandler {
|
||||
total_amount += amount;
|
||||
}
|
||||
|
||||
let sat_per_vbyte = params
|
||||
.get("fee_rate_sat_per_vbyte")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({
|
||||
"sat_per_vbyte": params.get("fee_rate_sat_per_vbyte")
|
||||
}))?;
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let sat_per_vbyte = if let Some(rate) = explicit_rate {
|
||||
rate as u64
|
||||
} else {
|
||||
let response = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v2/wallet/estimatefee/1"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Cannot estimate the next-block fee")?
|
||||
.error_for_status()
|
||||
.context("Next-block fee estimate rejected")?;
|
||||
let estimate: serde_json::Value = response.json().await?;
|
||||
super::fee_policy::estimated_sat_per_vbyte(&estimate)?
|
||||
};
|
||||
|
||||
info!(
|
||||
total_amount = total_amount,
|
||||
@@ -793,8 +784,6 @@ impl RpcHandler {
|
||||
"Creating PSBT for hardware wallet signing"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"outputs": lnd_outputs,
|
||||
|
||||
@@ -221,6 +221,10 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
anyhow::ensure!(job.snapshot().await.done,
|
||||
"A firmware flash is in progress; wait before reconnecting or changing radio settings");
|
||||
}
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
@@ -325,7 +329,16 @@ impl RpcHandler {
|
||||
{
|
||||
let service_arc = Arc::clone(&self.mesh_service);
|
||||
let config_for_apply = config.clone();
|
||||
let flash_jobs = Arc::clone(&self.flash_job);
|
||||
tokio::spawn(async move {
|
||||
// Serialize against flash registration. If a flash started
|
||||
// after this RPC saved settings, its completion applies them.
|
||||
let flash_guard = flash_jobs.read().await;
|
||||
if let Some(job) = flash_guard.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut service = service_arc.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
if let Err(e) = svc.configure(config_for_apply).await {
|
||||
|
||||
@@ -110,7 +110,8 @@ impl RpcHandler {
|
||||
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
||||
// from opening the identical port at the same time and corrupting
|
||||
// both operations' handshakes.
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
let flash_guard = self.flash_job.read().await;
|
||||
if let Some(job) = flash_guard.as_ref() {
|
||||
anyhow::ensure!(
|
||||
job.snapshot().await.done,
|
||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
||||
@@ -131,6 +132,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
let probe = mesh::listener::probe_device(&path).await?;
|
||||
drop(flash_guard);
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -985,28 +985,26 @@ pub(super) async fn get_app_config(
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => {
|
||||
let storage = crate::container::npm::resolve_storage().await?;
|
||||
let admin_port = allocator
|
||||
.allocate_or_get(app_id, 8081, 81)
|
||||
.await
|
||||
.unwrap_or(8081);
|
||||
let http_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8088, 80)
|
||||
.await
|
||||
.unwrap_or(8084);
|
||||
.unwrap_or(8088);
|
||||
let https_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
|
||||
.await
|
||||
.unwrap_or(8444);
|
||||
(
|
||||
vec![
|
||||
format!("{}:81", admin_port),
|
||||
format!("{}:80", http_port),
|
||||
format!("{}:443", https_port),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
format!("127.0.0.1:{}:81", admin_port),
|
||||
format!("127.0.0.1:{}:80", http_port),
|
||||
format!("127.0.0.1:{}:443", https_port),
|
||||
],
|
||||
storage.bind_mounts(),
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -693,7 +693,11 @@ impl RpcHandler {
|
||||
// These standalone web UIs have repeatedly lost host listeners
|
||||
// under Podman's rootless pasta backend while staying healthy internally.
|
||||
// Use slirp4netns/rootlessport for this standalone web UI.
|
||||
run_args.push("--network=slirp4netns:allow_host_loopback=true");
|
||||
run_args.push(if package_id == "nginx-proxy-manager" {
|
||||
"--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
} else {
|
||||
"--network=slirp4netns:allow_host_loopback=true"
|
||||
});
|
||||
} else if needs_archy_net(package_id) {
|
||||
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
|
||||
match tokio::process::Command::new("podman")
|
||||
@@ -1568,88 +1572,8 @@ autopilot.active=false\n",
|
||||
super::pine_ha::restart_home_assistant_if_running().await;
|
||||
}
|
||||
}
|
||||
if package_id == "filebrowser" {
|
||||
// Generate a random password (32 bytes, hex-encoded)
|
||||
let mut buf = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||
let password = hex::encode(buf);
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create HTTP client for FileBrowser hook: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Retry loop: FileBrowser may take time to initialize its SQLite database
|
||||
let mut password_changed = false;
|
||||
for attempt in 0..6u32 {
|
||||
let delay = if attempt == 0 { 5 } else { 10 };
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
|
||||
// Try to log in with default credentials
|
||||
let login_res = client
|
||||
.post("http://127.0.0.1:8083/api/login")
|
||||
.json(&serde_json::json!({"username": "admin", "password": "admin"}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let token = match login_res {
|
||||
Ok(resp) if resp.status().is_success() => match resp.text().await {
|
||||
Ok(t) => t.trim_matches('"').to_string(),
|
||||
Err(_) => continue,
|
||||
},
|
||||
_ => {
|
||||
debug!("FileBrowser not ready (attempt {}/6)", attempt + 1);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Change admin password
|
||||
let change_res = client
|
||||
.put("http://127.0.0.1:8083/api/users/1")
|
||||
.header("X-Auth", &token)
|
||||
.json(&serde_json::json!({"password": password}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match change_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let secret_dir = "/var/lib/archipelago/secrets/filebrowser";
|
||||
if let Err(e) = tokio::fs::create_dir_all(secret_dir).await {
|
||||
tracing::warn!("Failed to create filebrowser secrets dir: {}", e);
|
||||
}
|
||||
let pw_path = format!("{}/password", secret_dir);
|
||||
if let Err(e) = tokio::fs::write(&pw_path, &password).await {
|
||||
tracing::warn!("Failed to write filebrowser password: {}", e);
|
||||
}
|
||||
// Set restrictive permissions on the password file
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(
|
||||
&pw_path,
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
);
|
||||
}
|
||||
info!("FileBrowser admin password secured (default credentials replaced)");
|
||||
password_changed = true;
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
if !password_changed {
|
||||
tracing::warn!(
|
||||
"FileBrowser password could not be changed after 6 attempts — \
|
||||
default credentials (admin/admin) remain active"
|
||||
);
|
||||
}
|
||||
}
|
||||
// File Browser credentials are provisioned and verified before the
|
||||
// server starts. Never attempt a default-password change after launch.
|
||||
|
||||
// Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin)
|
||||
{
|
||||
@@ -1912,10 +1836,10 @@ autopilot.active=false\n",
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_filebrowser_token(&self) -> Result<serde_json::Value> {
|
||||
let secret_path = "/var/lib/archipelago/secrets/filebrowser/password";
|
||||
let password = tokio::fs::read_to_string(secret_path)
|
||||
.await
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
let credentials = crate::container::filebrowser::cloud_credentials(std::path::Path::new(
|
||||
"/var/lib/archipelago/secrets/filebrowser",
|
||||
))
|
||||
.await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
@@ -1924,7 +1848,7 @@ autopilot.active=false\n",
|
||||
|
||||
let resp = client
|
||||
.post("http://127.0.0.1:8083/api/login")
|
||||
.json(&serde_json::json!({"username": "admin", "password": password}))
|
||||
.json(&serde_json::json!({"username": credentials.username, "password": credentials.password}))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to FileBrowser")?;
|
||||
@@ -1954,17 +1878,16 @@ autopilot.active=false\n",
|
||||
super::validation::validate_app_id(app_id)?;
|
||||
|
||||
if app_id == "filebrowser" {
|
||||
let password =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password")
|
||||
.await
|
||||
.map(|p| p.trim().to_string())
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
let credentials = crate::container::filebrowser::cloud_credentials(
|
||||
std::path::Path::new("/var/lib/archipelago/secrets/filebrowser"),
|
||||
)
|
||||
.await?;
|
||||
return Ok(serde_json::json!({
|
||||
"title": "File Browser credentials",
|
||||
"description": "Use these credentials when File Browser asks you to sign in.",
|
||||
"credentials": [
|
||||
{ "label": "Username", "value": "admin" },
|
||||
{ "label": "Password", "value": password, "sensitive": true }
|
||||
{ "label": "Username", "value": credentials.username },
|
||||
{ "label": "Password", "value": credentials.password, "sensitive": true }
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1576,41 +1576,110 @@ async fn repair_netbird_network() {
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_container() {
|
||||
repair_nginx_proxy_manager_dirs().await;
|
||||
// Quadlet owns managed containers; its backed-up reconciliation applies
|
||||
// port and mount changes. Never remove a systemd-owned container here.
|
||||
if crate::container::quadlet::unit_exists("nginx-proxy-manager").await {
|
||||
return;
|
||||
}
|
||||
// Serialize repair so a second caller cannot overlap a replacement.
|
||||
static REPAIR: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
let _repair = REPAIR.lock().await;
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
return;
|
||||
}
|
||||
|
||||
install_log(
|
||||
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
|
||||
)
|
||||
.await;
|
||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
|
||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||
if let Err(error) = repair_legacy_nginx_proxy_manager().await {
|
||||
tracing::warn!(error = %error, "NPM legacy repair failed; persistent state preserved");
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
const NPM_PREVIOUS_CONTAINER: &str = "archy-npm-upgrade-previous";
|
||||
|
||||
async fn restore_failed_npm_repair() -> Result<()> {
|
||||
let removed = podman_control(&["rm", "-f", "--ignore", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
removed.status.success(),
|
||||
"cannot remove failed NPM replacement; previous container retained"
|
||||
);
|
||||
let renamed =
|
||||
podman_control(&["rename", NPM_PREVIOUS_CONTAINER, "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
renamed.status.success(),
|
||||
"cannot restore previous NPM container name"
|
||||
);
|
||||
let started = podman_control(&["start", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
started.status.success(),
|
||||
"previous NPM container restored but failed to start"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn repair_legacy_nginx_proxy_manager() -> Result<()> {
|
||||
let previous = podman_control(&["container", "exists", NPM_PREVIOUS_CONTAINER]).await?;
|
||||
anyhow::ensure!(previous.status.code() == Some(1),
|
||||
"NPM previous-container slot is occupied or cannot be inspected; preserve it and review interrupted repair before proceeding");
|
||||
let inspection = podman_control(&[
|
||||
"inspect",
|
||||
"nginx-proxy-manager",
|
||||
"--format",
|
||||
"{{json .Config.Env}}",
|
||||
])
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
inspection.status.success(),
|
||||
"cannot preserve NPM environment before repair"
|
||||
);
|
||||
let environment: Vec<String> =
|
||||
serde_json::from_slice(&inspection.stdout).context("invalid original NPM environment")?;
|
||||
let environment = npm_repair_environment(&environment)?;
|
||||
let storage = crate::container::npm::resolve_storage().await?;
|
||||
let mut manifest: archipelago_container::AppManifest = serde_yaml::from_str(include_str!(
|
||||
"../../../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||
))?;
|
||||
storage.apply(&mut manifest)?;
|
||||
let stopped = podman_control(&["stop", "--time", "30", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
stopped.status.success(),
|
||||
"could not stop NPM for a consistent backup"
|
||||
);
|
||||
if let Err(error) = crate::container::migration_backup::snapshot(
|
||||
&manifest,
|
||||
std::path::Path::new("/var/lib/archipelago"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = podman_control(&["start", "nginx-proxy-manager"]).await;
|
||||
return Err(error);
|
||||
}
|
||||
// Keep the original runtime definition for rollback, including its operator
|
||||
// options. Never delete it before the replacement has become ready.
|
||||
let renamed = podman_control(&["rename", "nginx-proxy-manager", NPM_PREVIOUS_CONTAINER]).await;
|
||||
if !renamed.as_ref().is_ok_and(|out| out.status.success()) {
|
||||
let _ = podman_control(&["start", "nginx-proxy-manager"]).await;
|
||||
anyhow::bail!("could not retain legacy NPM runtime; state backup preserved, inspect both container names before retrying");
|
||||
}
|
||||
let replacement = async {
|
||||
recreate_nginx_proxy_manager_container(&storage, &environment).await?;
|
||||
anyhow::ensure!(
|
||||
wait_for_runtime_host_port("nginx-proxy-manager", 8081, 180).await,
|
||||
"replacement NPM admin listener did not become ready"
|
||||
);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = replacement {
|
||||
restore_failed_npm_repair()
|
||||
.await
|
||||
.context("restoring previous NPM after replacement failure")?;
|
||||
return Err(error);
|
||||
}
|
||||
let removed = podman_control(&["rm", NPM_PREVIOUS_CONTAINER]).await?;
|
||||
anyhow::ensure!(
|
||||
removed.status.success(),
|
||||
"replacement ready but previous NPM cleanup failed; rollback container retained"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
@@ -1645,36 +1714,75 @@ async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to create nginx-proxy-manager data directories")?;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
fn npm_repair_environment(values: &[String]) -> Result<Vec<(String, String)>> {
|
||||
values.iter().map(|value| {
|
||||
let (key, value) = value.split_once('=').context("invalid NPM environment entry")?;
|
||||
anyhow::ensure!(!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
"unsupported NPM environment name; original container preserved");
|
||||
anyhow::ensure!(!value.contains(['\n', '\r', '\0']),
|
||||
"NPM environment requires explicit migration of a multiline value; original container preserved");
|
||||
Ok((key.to_owned(), value.to_owned()))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
|
||||
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
|
||||
struct NpmRepairEnvironmentFile(std::path::PathBuf);
|
||||
|
||||
impl NpmRepairEnvironmentFile {
|
||||
fn create(environment: &[(String, String)]) -> Result<Self> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let path = std::env::temp_dir().join(format!(".archy-npm-env-{}", uuid::Uuid::new_v4()));
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)?;
|
||||
let guard = Self(path);
|
||||
for (key, value) in environment {
|
||||
writeln!(file, "{key}={value}")?;
|
||||
}
|
||||
file.sync_all()?;
|
||||
Ok(guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NpmRepairEnvironmentFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container(
|
||||
storage: &crate::container::npm::Storage,
|
||||
environment: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
// Existing directories and ownership came from the active runtime. Never
|
||||
// create a second database tree or recursively rewrite data permissions.
|
||||
for directory in [&storage.data, &storage.certificates] {
|
||||
anyhow::ensure!(
|
||||
std::path::Path::new(directory).is_dir(),
|
||||
"NPM persistent directory is missing"
|
||||
);
|
||||
}
|
||||
|
||||
// This repair changes connectivity, not NPM's application version. Keep
|
||||
// the exact old image so rollback never starts an older binary against a
|
||||
// database that an incidental mutable-tag update may have migrated.
|
||||
let image_output =
|
||||
podman_control(&["inspect", NPM_PREVIOUS_CONTAINER, "--format", "{{.Image}}"]).await?;
|
||||
anyhow::ensure!(
|
||||
image_output.status.success(),
|
||||
"cannot resolve original NPM image for repair"
|
||||
);
|
||||
let image = String::from_utf8(image_output.stdout)?.trim().to_string();
|
||||
anyhow::ensure!(!image.is_empty(), "original NPM image is missing");
|
||||
let mut args = vec![
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
"nginx-proxy-manager".to_string(),
|
||||
"--restart=unless-stopped".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24".to_string(),
|
||||
"--cap-drop=ALL".to_string(),
|
||||
"--security-opt=no-new-privileges:true".to_string(),
|
||||
"--pids-limit=4096".to_string(),
|
||||
@@ -1682,24 +1790,32 @@ async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
||||
args.extend([
|
||||
"-p".to_string(),
|
||||
"8081:81".to_string(),
|
||||
"127.0.0.1:8081:81".to_string(),
|
||||
"-p".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"127.0.0.1:8088:80".to_string(),
|
||||
"-p".to_string(),
|
||||
"8444:443".to_string(),
|
||||
"127.0.0.1:8444:443".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
format!("{}:/data", storage.data),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
format!("{}:/etc/letsencrypt", storage.certificates),
|
||||
"--memory".to_string(),
|
||||
get_memory_limit("nginx-proxy-manager").to_string(),
|
||||
"--cpus=2".to_string(),
|
||||
]);
|
||||
args.extend(get_health_check_args("nginx-proxy-manager", ""));
|
||||
// Keep values out of argv/logs AND out of Podman's host environment
|
||||
// (a container's PATH or LD_PRELOAD must never alter the host command).
|
||||
let env_file = NpmRepairEnvironmentFile::create(environment)?;
|
||||
args.extend([
|
||||
"--env-file".to_string(),
|
||||
env_file.0.to_string_lossy().into_owned(),
|
||||
]);
|
||||
args.push(image);
|
||||
|
||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let output = podman_control(&refs).await?;
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.args(&args);
|
||||
let output = command_with_timeout(command, Duration::from_secs(120), "NPM replacement").await?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman run nginx-proxy-manager failed: {}",
|
||||
@@ -1767,7 +1883,7 @@ fn runtime_host_ports(container_name: &str) -> Vec<u16> {
|
||||
"vaultwarden" => vec![8082],
|
||||
"gitea" => vec![3001, 2222, 3000],
|
||||
"nextcloud" => vec![8085],
|
||||
"nginx-proxy-manager" => vec![8081, 8084, 8444],
|
||||
"nginx-proxy-manager" => vec![8081, 8088, 8444],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ports
|
||||
@@ -1778,7 +1894,7 @@ fn with_legacy_extra_ports(container_name: &str, mut ports: Vec<u16>) -> Vec<u16
|
||||
ports.push(3000);
|
||||
}
|
||||
if container_name == "nginx-proxy-manager" {
|
||||
for port in [8084, 8444] {
|
||||
for port in [8088, 8444] {
|
||||
if !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
@@ -1843,6 +1959,7 @@ async fn wait_for_runtime_host_port(container_name: &str, port: u16, timeout_sec
|
||||
loop {
|
||||
let ready = match container_name {
|
||||
"uptime-kuma" => http_host_port_ready(port, "/").await,
|
||||
"nginx-proxy-manager" => http_host_port_ready(port, "/api/").await,
|
||||
_ => tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok(),
|
||||
@@ -2151,6 +2268,38 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn npm_environment_backup_is_private_exact_and_removed_on_drop() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let values = vec![
|
||||
"DB_PASSWORD=fixture=a b".to_string(),
|
||||
"PATH=/container/only".to_string(),
|
||||
];
|
||||
let parsed = super::npm_repair_environment(&values).unwrap();
|
||||
let host_path = std::env::var_os("PATH");
|
||||
let path = {
|
||||
let file = super::NpmRepairEnvironmentFile::create(&parsed).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&file.0).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&file.0).unwrap(),
|
||||
"DB_PASSWORD=fixture=a b\nPATH=/container/only\n"
|
||||
);
|
||||
assert_eq!(std::env::var_os("PATH"), host_path);
|
||||
file.0.clone()
|
||||
};
|
||||
assert!(!path.exists());
|
||||
for value in [
|
||||
"INVALID",
|
||||
"=empty key",
|
||||
"KEY=value\nINJECTED=true",
|
||||
"--env=value",
|
||||
] {
|
||||
assert!(super::npm_repair_environment(&[value.to_string()]).is_err());
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user