Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
/// LND rejects RPCs with "server is still in the process of starting" for a
|
||||
/// short window after wallet unlock (p2p/graph subsystems still loading).
|
||||
/// Transient by design — match it so callers retry and, if it persists,
|
||||
/// surface a calm notice instead of a scary failure. The exact phrase below
|
||||
/// is what the frontend keys its softer (non-red) styling on.
|
||||
fn lnd_still_starting(msg: &str) -> bool {
|
||||
let m = msg.to_ascii_lowercase();
|
||||
m.contains("in the process of starting") || m.contains("server is still starting")
|
||||
}
|
||||
|
||||
/// User-facing text for the still-starting state. Deliberately calm: this is
|
||||
/// a "wait a moment", not an error.
|
||||
const LND_STARTING_MSG: &str = "Your Lightning node is still finishing its startup — this \
|
||||
usually takes a minute or two after the node comes online. Please try again shortly.";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
local_balance: i64,
|
||||
remote_balance: i64,
|
||||
active: bool,
|
||||
status: String,
|
||||
channel_point: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
closing_txid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelListResult {
|
||||
channels: Vec<ChannelInfo>,
|
||||
total_inbound: i64,
|
||||
total_outbound: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndListChannelsResponse {
|
||||
channels: Option<Vec<LndChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
active: Option<bool>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndPendingChannelsResponse {
|
||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||
// Cooperative closes waiting for their closing tx to confirm
|
||||
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
|
||||
// Force closes serving out their timelock
|
||||
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingOpenChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndWaitingCloseChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndForceClosingChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingChannel {
|
||||
remote_node_pub: Option<String>,
|
||||
capacity: Option<String>,
|
||||
local_balance: Option<String>,
|
||||
remote_balance: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
impl LndPendingChannel {
|
||||
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
|
||||
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
|
||||
capacity: parse(&self.capacity),
|
||||
local_balance: parse(&self.local_balance),
|
||||
remote_balance: parse(&self.remote_balance),
|
||||
active: false,
|
||||
status: status.into(),
|
||||
channel_point: self.channel_point.unwrap_or_default(),
|
||||
closing_txid: closing_txid.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndClosedChannelsResponse {
|
||||
channels: Option<Vec<LndClosedChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndClosedChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
settled_balance: Option<String>,
|
||||
close_type: Option<String>,
|
||||
closing_tx_hash: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
close_height: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ClosedChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
settled_balance: i64,
|
||||
close_type: String,
|
||||
closing_tx_hash: String,
|
||||
channel_point: String,
|
||||
close_height: i64,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let channels_resp: LndListChannelsResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND channels response")?;
|
||||
|
||||
let pending_resp: LndPendingChannelsResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/pending"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or_default(),
|
||||
Err(_) => LndPendingChannelsResponse::default(),
|
||||
};
|
||||
|
||||
let channels: Vec<ChannelInfo> = channels_resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| {
|
||||
let capacity: i64 = ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let local: i64 = ch
|
||||
.local_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let remote: i64 = ch
|
||||
.remote_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: ch.active.unwrap_or(false),
|
||||
status: if ch.active.unwrap_or(false) {
|
||||
"active".into()
|
||||
} else {
|
||||
"inactive".into()
|
||||
},
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
closing_txid: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||
if let Some(ch) = pch.channel {
|
||||
pending_channels.push(ch.into_channel_info("pending_open", None));
|
||||
}
|
||||
}
|
||||
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
|
||||
if let Some(ch) = wch.channel {
|
||||
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
|
||||
}
|
||||
}
|
||||
for fch in pending_resp
|
||||
.pending_force_closing_channels
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Some(ch) = fch.channel {
|
||||
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
|
||||
}
|
||||
}
|
||||
|
||||
let total_local: i64 = channels.iter().map(|c| c.local_balance).sum();
|
||||
let total_remote: i64 = channels.iter().map(|c| c.remote_balance).sum();
|
||||
|
||||
let mut all_channels = channels;
|
||||
all_channels.extend(pending_channels);
|
||||
|
||||
let result = ChannelListResult {
|
||||
channels: all_channels,
|
||||
total_inbound: total_remote,
|
||||
total_outbound: total_local,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_openchannel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let pubkey = params
|
||||
.get("pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid pubkey: must be 66-character hex string"
|
||||
));
|
||||
}
|
||||
|
||||
if amount < 20000 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Channel amount must be at least 20,000 sats"
|
||||
));
|
||||
}
|
||||
if amount > 16_777_215 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Channel amount exceeds maximum (16,777,215 sats)"
|
||||
));
|
||||
}
|
||||
|
||||
let private = params
|
||||
.get("private")
|
||||
.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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
peer = pubkey,
|
||||
amount = amount,
|
||||
private = private,
|
||||
"Opening Lightning channel"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// First connect to the peer if an address is provided.
|
||||
// perm=false makes LND connect synchronously, so the peer is online
|
||||
// (or we get a real error) before we attempt the channel open.
|
||||
// perm=true queues the connection in the background and returns
|
||||
// immediately, which makes the subsequent open race and fail with
|
||||
// "peer is not online".
|
||||
if let Some(addr) = params.get("address").and_then(|v| v.as_str()) {
|
||||
// Validate peer address format (host:port)
|
||||
if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') {
|
||||
return Err(anyhow::anyhow!("Invalid peer address format"));
|
||||
}
|
||||
let connect_body = serde_json::json!({
|
||||
"addr": { "pubkey": pubkey, "host": addr },
|
||||
"perm": false,
|
||||
"timeout": "30"
|
||||
});
|
||||
// Right after wallet unlock, LND's RPC answers while its p2p
|
||||
// server is still spinning up, and every connect attempt gets
|
||||
// "server is still in the process of starting". That's a
|
||||
// transient state, not a failure — it clears in seconds — so
|
||||
// retry quietly for ~30s before surfacing a calm, non-scary
|
||||
// notice (the frontend styles LND_STARTING_MSG as info, not red).
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
|
||||
if connect_resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
let body: serde_json::Value = connect_resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND returns an error if we already have this peer — that is fine
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
if lnd_still_starting(msg) && attempt < 5 {
|
||||
attempt += 1;
|
||||
info!(attempt, "LND still starting — retrying peer connect in 6s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
continue;
|
||||
}
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
}
|
||||
}
|
||||
|
||||
let mut open_body = serde_json::json!({
|
||||
"node_pubkey_string": pubkey,
|
||||
"local_funding_amount": amount.to_string(),
|
||||
"private": private,
|
||||
});
|
||||
if let Some(tc) = target_conf {
|
||||
open_body["target_conf"] = serde_json::json!(tc);
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
// LND REST encodes uint64 as a JSON string
|
||||
open_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&open_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to open channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse open channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp: LndClosedChannelsResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND closed channels response")?;
|
||||
|
||||
let channels: Vec<ClosedChannelInfo> = resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| ClosedChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity: ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
settled_balance: ch
|
||||
.settled_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
close_type: ch.close_type.unwrap_or_default(),
|
||||
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
close_height: ch.close_height.unwrap_or(0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "channels": channels }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let channel_point = params
|
||||
.get("channel_point")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)")
|
||||
})?;
|
||||
|
||||
let parts: Vec<&str> = channel_point.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid channel_point format. Expected 'txid:output_index'"
|
||||
));
|
||||
}
|
||||
// Validate txid is 64-char hex and output_index is numeric
|
||||
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid txid in channel_point: must be 64-character hex"
|
||||
));
|
||||
}
|
||||
if parts[1].parse::<u32>().is_err() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid output_index in channel_point: must be a number"
|
||||
));
|
||||
}
|
||||
|
||||
let force = params
|
||||
.get("force")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
info!(
|
||||
channel_point = channel_point,
|
||||
force = force,
|
||||
"Closing Lightning channel"
|
||||
);
|
||||
|
||||
let (_, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// The close endpoint is server-streaming: LND holds the connection
|
||||
// open and emits updates until the closing tx CONFIRMS on-chain
|
||||
// (potentially hours). Reading the whole body hangs the RPC even
|
||||
// though the close already went through, and the shared lnd_client's
|
||||
// 15s total timeout would abort the stream mid-read. Use a dedicated
|
||||
// client and return as soon as the first streamed update arrives.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
|
||||
let url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to close channel")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
|
||||
// First streamed line is {"result":{"close_pending":…}} on success or
|
||||
// {"error":…} — the stream reports errors in-band after a 200.
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
buf.extend_from_slice(&chunk);
|
||||
let line = match buf.iter().position(|&b| b == b'\n') {
|
||||
Some(pos) => &buf[..pos],
|
||||
None => &buf[..],
|
||||
};
|
||||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
|
||||
return Ok::<_, anyhow::Error>(Some(v));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await;
|
||||
|
||||
match first_update {
|
||||
Ok(Ok(Some(update))) => {
|
||||
if let Some(err) = update.get("error") {
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
// txid arrives base64-encoded in internal byte order; flip it
|
||||
// into the display order explorers use.
|
||||
use base64::Engine as _;
|
||||
let closing_txid = update
|
||||
.pointer("/result/close_pending/txid")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(|mut bytes| {
|
||||
bytes.reverse();
|
||||
hex::encode(bytes)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
info!(channel_point, closing_txid, "Channel close initiated");
|
||||
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
|
||||
}
|
||||
Ok(Ok(None)) => Err(anyhow::anyhow!(
|
||||
"LND ended the close stream without an update — check the channel list"
|
||||
)),
|
||||
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
|
||||
// No update inside the window: the close is almost certainly still
|
||||
// negotiating with the peer — report initiated, the channel list
|
||||
// will show it under Closing.
|
||||
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LndInfo {
|
||||
alias: String,
|
||||
num_active_channels: u32,
|
||||
num_peers: u32,
|
||||
synced_to_chain: bool,
|
||||
block_height: u64,
|
||||
balance_sats: i64,
|
||||
channel_balance_sats: i64,
|
||||
pending_open_balance: i64,
|
||||
/// This node's Lightning identity pubkey, or `None` when LND did not
|
||||
/// report one or reported one that is not a compressed secp256k1 key.
|
||||
/// Never fabricated: the caller can tell "not available" from "available".
|
||||
identity_pubkey: Option<String>,
|
||||
/// The connection URIs LND advertises for this node (`pubkey@host:port`).
|
||||
/// Empty when LND advertises none — an honest absence, not a placeholder.
|
||||
uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndGetInfoResponse {
|
||||
alias: Option<String>,
|
||||
num_active_channels: Option<u32>,
|
||||
num_peers: Option<u32>,
|
||||
synced_to_chain: Option<bool>,
|
||||
block_height: Option<u64>,
|
||||
#[serde(default)]
|
||||
identity_pubkey: Option<String>,
|
||||
#[serde(default)]
|
||||
uris: Vec<String>,
|
||||
}
|
||||
|
||||
/// A compressed secp256k1 pubkey is 66 hexadecimal characters. Mirrors the
|
||||
/// check `handle_lnd_openchannel` performs before dialling a peer, so a key
|
||||
/// this function passes is one that handler would accept.
|
||||
fn is_valid_identity_pubkey(pubkey: &str) -> bool {
|
||||
pubkey.len() == 66 && pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Map LND's reported identity onto the RPC response.
|
||||
///
|
||||
/// Split out from the HTTP flow so it is testable without a live LND. A
|
||||
/// malformed pubkey yields `None` rather than propagating a key that
|
||||
/// `lnd.openchannel` would later reject — surfacing the problem here, where
|
||||
/// the operator is reading their own node's identity, beats surfacing it at
|
||||
/// the moment they try to open a channel.
|
||||
fn map_identity(get_info: &LndGetInfoResponse) -> (Option<String>, Vec<String>) {
|
||||
let identity_pubkey = match get_info.identity_pubkey.as_deref() {
|
||||
Some(pubkey) if is_valid_identity_pubkey(pubkey) => Some(pubkey.to_string()),
|
||||
Some(bad) => {
|
||||
tracing::warn!(
|
||||
len = bad.len(),
|
||||
"LND getinfo returned an identity_pubkey that is not 66 hex characters — \
|
||||
reporting no identity rather than a key lnd.openchannel would reject"
|
||||
);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
(identity_pubkey, get_info.uris.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndChannelBalanceResponse {
|
||||
local_balance: Option<LndAmount>,
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
let info = LndInfo {
|
||||
identity_pubkey,
|
||||
uris,
|
||||
alias: get_info.alias.unwrap_or_default(),
|
||||
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
}
|
||||
|
||||
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
|
||||
/// for building lndconnect:// URIs in the frontend.
|
||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||
|
||||
// Read and encode TLS cert (PEM -> DER -> base64url)
|
||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||
.await
|
||||
.context("Failed to read LND TLS certificate")?;
|
||||
let cert_der_b64: String = cert_pem
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("-----"))
|
||||
.collect();
|
||||
let cert_der = base64::engine::general_purpose::STANDARD
|
||||
.decode(&cert_der_b64)
|
||||
.context("Failed to decode PEM base64")?;
|
||||
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
||||
|
||||
// Read and encode macaroon (binary -> base64url)
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_b64url =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
||||
|
||||
// Read Tor onion address -- check system Tor path first, then legacy
|
||||
let tor_onion = {
|
||||
let mut onion = None;
|
||||
for path in &[
|
||||
"/var/lib/archipelago/tor-hostnames/lnd",
|
||||
"/var/lib/tor/hidden_service_lnd/hostname",
|
||||
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
|
||||
] {
|
||||
if let Ok(addr) = tokio::fs::read_to_string(path).await {
|
||||
let addr = addr.trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Try sudo for system Tor dirs (owned by debian-tor, 0700)
|
||||
if let Ok(output) = tokio::process::Command::new("sudo")
|
||||
.args(["cat", path])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success() {
|
||||
let addr = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if addr.ends_with(".onion") {
|
||||
onion = Some(addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onion
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"cert_base64url": cert_b64url,
|
||||
"macaroon_base64url": macaroon_b64url,
|
||||
"tor_onion": tor_onion,
|
||||
"rest_port": 18080,
|
||||
"grpc_port": 10009,
|
||||
}))
|
||||
}
|
||||
|
||||
/// lnd.export-channel-backup -- Export all channel static backups (SCB).
|
||||
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach LND REST API")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("LND returned {}", resp.status());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await.context("Invalid JSON from LND")?;
|
||||
|
||||
// Extract the multi_chan_backup bytes
|
||||
let backup_b64 = data
|
||||
.get("multi_chan_backup")
|
||||
.and_then(|m| m.get("multi_chan_backup"))
|
||||
.and_then(|b| b.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"backup": backup_b64,
|
||||
"channel_count": data.get("multi_chan_backup")
|
||||
.and_then(|m| m.get("chan_points"))
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0),
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
fn parse(body: &str) -> LndGetInfoResponse {
|
||||
serde_json::from_str(body).expect("LND getinfo body must deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_body_yields_identity_and_uris() {
|
||||
let parsed = parse(&format!(
|
||||
r#"{{"alias":"archy","identity_pubkey":"{GOOD_PUBKEY}",
|
||||
"uris":["{GOOD_PUBKEY}@1.2.3.4:9735","{GOOD_PUBKEY}@abcd.onion:9735"]}}"#
|
||||
));
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert_eq!(pubkey.as_deref(), Some(GOOD_PUBKEY));
|
||||
assert_eq!(
|
||||
uris.len(),
|
||||
2,
|
||||
"both advertised URIs must survive the mapping"
|
||||
);
|
||||
assert!(uris[0].starts_with(GOOD_PUBKEY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_fields_yield_honest_absence_not_a_fabricated_identity() {
|
||||
// The pre-existing fields must still deserialize with the new ones absent —
|
||||
// this is the body every node running an older LND build returns.
|
||||
let parsed = parse(r#"{"alias":"archy","num_peers":3,"synced_to_chain":true}"#);
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert!(pubkey.is_none(), "must not invent an identity");
|
||||
assert!(uris.is_empty(), "must not invent a URI");
|
||||
assert_eq!(parsed.alias.as_deref(), Some("archy"));
|
||||
assert_eq!(parsed.num_peers, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_pubkey_is_dropped_rather_than_propagated() {
|
||||
// Too short, non-hex, and empty must all be refused. Propagating any of
|
||||
// them would push the failure to lnd.openchannel, far from the cause.
|
||||
for bad in ["deadbeef", "", &"z".repeat(66), &GOOD_PUBKEY[..65]] {
|
||||
let parsed = parse(&format!(r#"{{"identity_pubkey":"{bad}"}}"#));
|
||||
let (pubkey, _) = map_identity(&parsed);
|
||||
assert!(
|
||||
pubkey.is_none(),
|
||||
"malformed pubkey {bad:?} must map to None, not be forwarded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_pubkey_does_not_discard_the_advertised_uris() {
|
||||
// The two facts are independent: a bad identity must not silently cost
|
||||
// the caller the URI list, which is the datum the picker actually needs.
|
||||
let parsed = parse(&format!(
|
||||
r#"{{"identity_pubkey":"nope","uris":["{GOOD_PUBKEY}@1.2.3.4:9735"]}}"#
|
||||
));
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert!(pubkey.is_none());
|
||||
assert_eq!(uris.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_pubkey_shape_matches_the_openchannel_rule() {
|
||||
assert!(is_valid_identity_pubkey(GOOD_PUBKEY));
|
||||
assert!(is_valid_identity_pubkey(&"0".repeat(66)));
|
||||
assert!(!is_valid_identity_pubkey(&"0".repeat(65)));
|
||||
assert!(!is_valid_identity_pubkey(&"0".repeat(67)));
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
//! LND macaroon rotation, driven from the dashboard.
|
||||
//!
|
||||
//! A macaroon is a bearer token: whoever holds it can spend from this node's
|
||||
//! Lightning wallet. Anything that ever read one — a leaked endpoint, a shared
|
||||
//! screenshot, a paired phone that has since been lost, a BTCPay instance that
|
||||
//! ran a version with a published vulnerability — keeps that ability until the
|
||||
//! macaroons are rotated. Rotation is therefore a routine operator action, and
|
||||
//! before this module the only way to perform it was to SSH into the node and
|
||||
//! run `scripts/security/rotate-lnd-macaroon.sh` by hand.
|
||||
//!
|
||||
//! ## What rotation actually does
|
||||
//!
|
||||
//! LND derives every macaroon it issues from a root key in `macaroons.db`.
|
||||
//! Remove that root key and the issued macaroon files, restart, and LND mints a
|
||||
//! fresh root key and a fresh set of macaroons on unlock. Every previously
|
||||
//! issued macaroon — including any an attacker holds — stops verifying.
|
||||
//!
|
||||
//! ## Why funds and channels survive
|
||||
//!
|
||||
//! Macaroons are bearer tokens, not keys. Coins live in `wallet.db` and channel
|
||||
//! state in `channel.db`; channels are secured by the node's identity and
|
||||
//! channel keys, none of which derive from the macaroon root key. This code
|
||||
//! never opens, moves or deletes either database. What it does instead is
|
||||
//! *prove* they survived: it records the node's identity pubkey and channel
|
||||
//! census before rotating and refuses to report success if either changed.
|
||||
//!
|
||||
//! Deliberately NOT asserted: that `wallet.db` is byte-identical. btcwallet
|
||||
//! records chain-sync progress inside it, so the file legitimately changes on
|
||||
//! every start — asserting byte-identity would fire a frightening false alarm
|
||||
//! on a completely healthy rotation.
|
||||
//!
|
||||
//! ## What it never does
|
||||
//!
|
||||
//! No macaroon *content* is read into a response, an error, a log line or the
|
||||
//! progress feed the UI polls. Everything reported is a SHA-256 digest or a
|
||||
//! byte count — enough to prove the material changed without disclosing it to
|
||||
//! whoever is looking at the screen.
|
||||
//!
|
||||
//! It also cannot reach LND's destructive wallet-recovery path: the restart
|
||||
//! unlocks via `unlock_existing_wallet_no_wipe`, so a wallet whose password
|
||||
//! this node does not hold surfaces as a failed rotation, never as a wipe.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
/// LND's mainnet macaroon directory. 0700 and owned by the container's mapped
|
||||
/// uid, so every read/write below goes through `sudo -n`.
|
||||
const LND_MAINNET_DIR: &str = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet";
|
||||
|
||||
/// Quadlet service for the core LND app. Older nodes run LND as a plain podman
|
||||
/// container with no unit; `restart_lnd` handles both.
|
||||
const LND_SERVICE: &str = "lnd.service";
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Where the orchestrator materialises app secrets. Hardcoded to match
|
||||
/// `prod_orchestrator`'s own default rather than derived from `config.data_dir`
|
||||
/// — writing the BTCPay connection string anywhere the orchestrator does not
|
||||
/// read it would be worse than not writing it at all, because it would look
|
||||
/// like it worked.
|
||||
const SECRETS_DIR: &str = "/var/lib/archipelago/secrets";
|
||||
|
||||
/// Longest we wait for LND to mint a fresh `admin.macaroon` after the restart.
|
||||
/// Generous on purpose: LND opens its databases before serving anything, which
|
||||
/// is minutes on a loaded node.
|
||||
const MACAROON_WAIT_SECS: u64 = 900;
|
||||
|
||||
// ── Progress the UI polls ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum StepState {
|
||||
Pending,
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
/// Ran, decided there was nothing to do, and said so. Distinct from `Done`
|
||||
/// so "BTCPay has no internal Lightning node" never reads as "BTCPay was
|
||||
/// reconnected".
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RotationStep {
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
state: StepState,
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Ordered because the UI renders it as a checklist and an operator watching a
|
||||
/// credential rotation should be able to see exactly how far it got.
|
||||
const STEPS: &[(&str, &str)] = &[
|
||||
(
|
||||
"preflight",
|
||||
"Check LND is healthy and record what must survive",
|
||||
),
|
||||
("backup", "Back up the current macaroon material"),
|
||||
("stop", "Stop Lightning"),
|
||||
("remove", "Remove the old root key and issued macaroons"),
|
||||
("start", "Start Lightning and unlock the wallet"),
|
||||
("verify", "Confirm the node and its channels are unchanged"),
|
||||
("btcpay", "Reconnect BTCPay Server to the new credentials"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(crate) struct RotationProgress {
|
||||
running: bool,
|
||||
/// `None` while running, then the verdict. Split from `running` so the UI
|
||||
/// can tell "in progress" from "finished and failed".
|
||||
ok: Option<bool>,
|
||||
started_at: Option<String>,
|
||||
finished_at: Option<String>,
|
||||
error: Option<String>,
|
||||
steps: Vec<RotationStep>,
|
||||
/// Where the old material was copied. Still secret — it is the old root key
|
||||
/// — so the UI tells the operator to delete it once clients are re-paired.
|
||||
backup_path: Option<String>,
|
||||
identity_pubkey: Option<String>,
|
||||
channels_before: Option<u32>,
|
||||
channels_after: Option<u32>,
|
||||
/// Digest of the freshly minted admin macaroon. A digest, never the token.
|
||||
new_admin_macaroon_sha256: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RotationProgress {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
ok: None,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
error: None,
|
||||
steps: STEPS
|
||||
.iter()
|
||||
.map(|(key, label)| RotationStep {
|
||||
key,
|
||||
label,
|
||||
state: StepState::Pending,
|
||||
detail: None,
|
||||
})
|
||||
.collect(),
|
||||
backup_path: None,
|
||||
identity_pubkey: None,
|
||||
channels_before: None,
|
||||
channels_after: None,
|
||||
new_admin_macaroon_sha256: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RotationProgress {
|
||||
fn set(&mut self, key: &str, state: StepState, detail: Option<String>) {
|
||||
if let Some(step) = self.steps.iter_mut().find(|s| s.key == key) {
|
||||
step.state = state;
|
||||
if detail.is_some() {
|
||||
step.detail = detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rotation at a time, process-wide. Two concurrent rotations would race on
|
||||
/// the same files with LND stopped underneath them.
|
||||
fn progress() -> &'static Mutex<RotationProgress> {
|
||||
static PROGRESS: OnceLock<Mutex<RotationProgress>> = OnceLock::new();
|
||||
PROGRESS.get_or_init(|| Mutex::new(RotationProgress::default()))
|
||||
}
|
||||
|
||||
fn with_progress<F: FnOnce(&mut RotationProgress)>(f: F) {
|
||||
if let Ok(mut guard) = progress().lock() {
|
||||
f(&mut guard);
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot() -> RotationProgress {
|
||||
progress()
|
||||
.lock()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_else(|e| e.into_inner().clone())
|
||||
}
|
||||
|
||||
// ── Host helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// `sudo -n <args>`, capturing output. Non-interactive: a node whose sudoers
|
||||
/// does not permit this fails loudly here rather than hanging on a prompt.
|
||||
async fn sudo(args: &[&str]) -> Result<std::process::Output> {
|
||||
let mut cmd = tokio::process::Command::new("sudo");
|
||||
cmd.arg("-n").args(args);
|
||||
cmd.output()
|
||||
.await
|
||||
.with_context(|| format!("sudo -n {}", args.join(" ")))
|
||||
}
|
||||
|
||||
async fn sudo_ok(args: &[&str]) -> Result<()> {
|
||||
let out = sudo(args).await?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"sudo {} exited {}: {}",
|
||||
args.join(" "),
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SHA-256 of a root-owned file, or `None` if it isn't there. Only ever the
|
||||
/// digest — the file's bytes never enter this process.
|
||||
async fn digest_as_root(path: &str) -> Option<String> {
|
||||
let out = sudo(&["sha256sum", path]).await.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Every file rotation replaces: the issued macaroons plus the root key they
|
||||
/// derive from.
|
||||
///
|
||||
/// Enumerated with `sudo find` rather than a shell glob for a reason worth
|
||||
/// keeping: the directory is 0700 owned by the container's mapped uid, so a
|
||||
/// glob evaluated by this (unprivileged) process expands to nothing. It would
|
||||
/// silently make both the backup and the removal no-ops while every surrounding
|
||||
/// step still reported success.
|
||||
async fn macaroon_files() -> Result<Vec<String>> {
|
||||
let out = sudo(&[
|
||||
"find",
|
||||
LND_MAINNET_DIR,
|
||||
"-maxdepth",
|
||||
"1",
|
||||
"(",
|
||||
"-name",
|
||||
"*.macaroon",
|
||||
"-o",
|
||||
"-name",
|
||||
"macaroons.db",
|
||||
")",
|
||||
])
|
||||
.await?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"listing macaroon material failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// True when LND is managed by a generated Quadlet unit on this node. Nodes
|
||||
/// predating the Quadlet migration run a bare podman container instead, and
|
||||
/// stopping the wrong way there means either a no-op or an orphan.
|
||||
async fn lnd_has_quadlet_unit() -> bool {
|
||||
crate::container::quadlet::is_active(LND_SERVICE).await
|
||||
|| tokio::process::Command::new("systemctl")
|
||||
.args(["--user", "cat", LND_SERVICE])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn stop_lnd() -> Result<()> {
|
||||
if lnd_has_quadlet_unit().await {
|
||||
return crate::container::quadlet::stop_service(LND_SERVICE)
|
||||
.await
|
||||
.context("stopping lnd.service");
|
||||
}
|
||||
podman_scoped(&["stop", LND_CONTAINER]).await
|
||||
}
|
||||
|
||||
async fn start_lnd() -> Result<()> {
|
||||
if lnd_has_quadlet_unit().await {
|
||||
return crate::container::quadlet::enable_now(LND_SERVICE)
|
||||
.await
|
||||
.context("starting lnd.service");
|
||||
}
|
||||
podman_scoped(&["start", LND_CONTAINER]).await
|
||||
}
|
||||
|
||||
/// `podman` inside a transient user scope, matching how the orchestrator and
|
||||
/// health monitor drive rootless containers (keeps it out of the archipelago
|
||||
/// service's cgroup, so an archipelago restart doesn't take LND with it).
|
||||
async fn podman_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── LND facts ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Identity and channel census — the two things that must be identical either
|
||||
/// side of a rotation.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct LndCensus {
|
||||
channels_open: u32,
|
||||
channels_pending: u32,
|
||||
}
|
||||
|
||||
fn lnd_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
// LND serves its own self-signed cert on loopback; the macaroon, not
|
||||
// the certificate, is what authenticates this call.
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")
|
||||
}
|
||||
|
||||
/// `getinfo` using whatever macaroon is on disk right now. Returns the identity
|
||||
/// pubkey and census, or an error describing why LND could not answer.
|
||||
async fn read_census() -> Result<(String, LndCensus)> {
|
||||
let macaroon = super::read_lnd_admin_macaroon()
|
||||
.await
|
||||
.context("reading LND admin macaroon")?;
|
||||
let resp = lnd_client()?
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", hex::encode(&macaroon))
|
||||
.send()
|
||||
.await
|
||||
.context("LND is not answering on its REST port")?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("LND returned a response that is not JSON")?;
|
||||
let pubkey = body
|
||||
.get("identity_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"LND did not report an identity — it is most likely still starting or locked ({})",
|
||||
body.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("no detail")
|
||||
)
|
||||
})?;
|
||||
let num = |k: &str| body.get(k).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
Ok((
|
||||
pubkey,
|
||||
LndCensus {
|
||||
// Active + inactive, summed deliberately. `num_active_channels`
|
||||
// counts channels whose peer is currently online, so it legitimately
|
||||
// dips for minutes after ANY restart while peers reconnect —
|
||||
// asserting on it alone would abort a perfectly healthy rotation.
|
||||
// The total number of channels held is the real safety property.
|
||||
channels_open: num("num_active_channels") + num("num_inactive_channels"),
|
||||
channels_pending: num("num_pending_channels"),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Poll until LND answers `getinfo` with a fresh macaroon, or the budget runs
|
||||
/// out. Used after the restart, so "not ready yet" is the expected case for
|
||||
/// most of the wait.
|
||||
async fn wait_for_serving(deadline: std::time::Instant) -> Result<(String, LndCensus)> {
|
||||
let mut last = String::from("LND did not become reachable");
|
||||
while std::time::Instant::now() < deadline {
|
||||
match read_census().await {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) => last = format!("{e:#}"),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
anyhow::bail!("timed out waiting for LND to serve again: {last}")
|
||||
}
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
impl RpcHandler {
|
||||
/// Read-only picture of this node's Lightning credentials: when they were
|
||||
/// issued, what depends on them, and whether anything is already out of
|
||||
/// step. Never returns macaroon content.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_macaroon_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let admin_path = format!("{LND_MAINNET_DIR}/admin.macaroon");
|
||||
let installed = digest_as_root(&admin_path).await;
|
||||
|
||||
// `stat -c %y` on the macaroon is when LND last minted it, which is the
|
||||
// one date an operator actually wants ("am I still carrying credentials
|
||||
// from before that incident?").
|
||||
let issued_at = match sudo(&["stat", "-c", "%y", &admin_path]).await {
|
||||
Ok(out) if out.status.success() => Some(
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.chars()
|
||||
.take(19)
|
||||
.collect::<String>(),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let (identity_pubkey, census, lnd_error) = match read_census().await {
|
||||
Ok((pk, c)) => (Some(pk), Some(c), None),
|
||||
Err(e) => (None, None, Some(format!("{e:#}"))),
|
||||
};
|
||||
|
||||
// Whether BTCPay's inline copy still matches. `None` = BTCPay has no
|
||||
// internal Lightning node configured, which is a normal state and not a
|
||||
// problem to report.
|
||||
let btcpay_current = crate::container::lnd::btcpay_lnd_connection_is_current(
|
||||
std::path::Path::new(SECRETS_DIR),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"installed": installed.is_some(),
|
||||
"admin_macaroon_sha256": installed,
|
||||
"issued_at": issued_at,
|
||||
"identity_pubkey": identity_pubkey,
|
||||
"channels_open": census.map(|c| c.channels_open),
|
||||
"channels_pending": census.map(|c| c.channels_pending),
|
||||
"lnd_error": lnd_error,
|
||||
"btcpay_uses_internal_lnd": btcpay_current.is_some(),
|
||||
"btcpay_credential_current": btcpay_current,
|
||||
"rotation": snapshot(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Start a rotation. Password-gated and asynchronous.
|
||||
///
|
||||
/// Password-gated because invalidating every credential a wallet app holds
|
||||
/// is an operator action, and a session cookie only proves a browser was
|
||||
/// once logged in — the same reasoning as `node.rotate-identity` and TOTP
|
||||
/// setup, which both re-verify.
|
||||
///
|
||||
/// Asynchronous because the work takes minutes (LND's databases have to
|
||||
/// close and reopen); the HTTP request returns immediately and the UI polls
|
||||
/// `lnd.macaroon-rotation-progress`.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_rotate_macaroons(
|
||||
self: &Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let password = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Node password required to rotate Lightning credentials");
|
||||
}
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password verification failed");
|
||||
}
|
||||
|
||||
// Claim the slot and publish a fresh feed in one critical section, so a
|
||||
// second click cannot observe a half-reset progress object.
|
||||
{
|
||||
let mut guard = progress()
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("rotation state poisoned"))?;
|
||||
if guard.running {
|
||||
anyhow::bail!("A macaroon rotation is already running on this node");
|
||||
}
|
||||
*guard = RotationProgress {
|
||||
running: true,
|
||||
started_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
tokio::spawn(async move {
|
||||
let outcome = run_rotation(orchestrator).await;
|
||||
with_progress(|p| {
|
||||
p.running = false;
|
||||
p.finished_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
match &outcome {
|
||||
Ok(()) => p.ok = Some(true),
|
||||
Err(e) => {
|
||||
p.ok = Some(false);
|
||||
p.error = Some(format!("{e:#}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
match outcome {
|
||||
Ok(()) => tracing::info!("LND macaroon rotation completed"),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "LND macaroon rotation failed")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "status": "started" }))
|
||||
}
|
||||
|
||||
/// Poll the running (or last) rotation.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_macaroon_rotation_progress(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::to_value(snapshot())?)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The rotation itself ──────────────────────────────────────────────────────
|
||||
|
||||
/// Hold LND's lifecycle lock for the whole rotation, then do the work.
|
||||
///
|
||||
/// Between "stop LND" and "start LND" this owns a stopped container with its
|
||||
/// credential material deleted — the single worst moment for another actor to
|
||||
/// step in. Two would, unasked: the health monitor restarts any container it
|
||||
/// finds stopped, and the reconciler starts one whose unit is enabled. Either
|
||||
/// brings LND back up mid-deletion, and LND re-mints `macaroons.db` on unlock —
|
||||
/// so the deletion loop would race a live process writing that file, or
|
||||
/// "succeed" against material that had already been regenerated, leaving the
|
||||
/// operator told they had rotated while the old root key was still in service.
|
||||
///
|
||||
/// `app_ops::op_lock` is the mechanism both of those actors already consult
|
||||
/// (`lifecycle_op_in_flight`, via `lifecycle_op_covers_container` in the health
|
||||
/// monitor), and it also serialises against the package.start/stop/restart
|
||||
/// workers, so an operator hitting "Restart" on Lightning mid-rotation queues
|
||||
/// instead of interleaving.
|
||||
///
|
||||
/// Chosen over the `user-stopped` marker that `recreate_wallet_destructively`
|
||||
/// uses for its own window: that marker is a file on disk, so a rotation that
|
||||
/// died between marking and clearing would leave Lightning suppressed
|
||||
/// *permanently*, fixable only by finding and editing JSON on the node. This
|
||||
/// guard releases when it drops, on every path including a panic.
|
||||
async fn run_rotation(
|
||||
orchestrator: Option<Arc<dyn crate::container::ContainerOrchestrator>>,
|
||||
) -> Result<()> {
|
||||
let lock = crate::app_ops::op_lock(LND_CONTAINER);
|
||||
// Fail fast rather than queue. This is a button someone just pressed: a
|
||||
// silent wait behind a start/stop/restart that may itself take minutes reads
|
||||
// as "nothing happened", and the honest answer is short.
|
||||
let _guard = lock.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"another Lightning start/stop/restart is in progress on this node — \
|
||||
wait for it to finish and try again"
|
||||
)
|
||||
})?;
|
||||
rotate_with_lnd_pinned(orchestrator).await
|
||||
}
|
||||
|
||||
async fn rotate_with_lnd_pinned(
|
||||
orchestrator: Option<Arc<dyn crate::container::ContainerOrchestrator>>,
|
||||
) -> Result<()> {
|
||||
// 1. Preflight — establish what must survive, while LND can still be asked.
|
||||
with_progress(|p| p.set("preflight", StepState::Running, None));
|
||||
let files = macaroon_files().await?;
|
||||
if files.is_empty() {
|
||||
with_progress(|p| p.set("preflight", StepState::Failed, None));
|
||||
anyhow::bail!(
|
||||
"no macaroon material found in {LND_MAINNET_DIR} — nothing to rotate, and \
|
||||
restarting Lightning for no reason would be a pointless outage"
|
||||
);
|
||||
}
|
||||
let (pubkey_before, census_before) = read_census().await.context(
|
||||
"refusing to rotate: LND is not answering, so there would be no baseline to prove your \
|
||||
channels survived. Start Lightning, wait for it to sync, and try again",
|
||||
)?;
|
||||
with_progress(|p| {
|
||||
p.identity_pubkey = Some(pubkey_before.clone());
|
||||
p.channels_before = Some(census_before.channels_open);
|
||||
p.set(
|
||||
"preflight",
|
||||
StepState::Done,
|
||||
Some(format!(
|
||||
"{} channel(s) open, {} pending — these must be identical afterwards",
|
||||
census_before.channels_open, census_before.channels_pending
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
// 2. Back up, so a mistake is recoverable. Verified by count: a backup that
|
||||
// silently copied nothing is the one failure that makes the deletion
|
||||
// below unrecoverable.
|
||||
with_progress(|p| p.set("backup", StepState::Running, None));
|
||||
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let backup = format!("/var/lib/archipelago/lnd/macaroon-rotation-{stamp}");
|
||||
sudo_ok(&["mkdir", "-p", &backup]).await?;
|
||||
sudo_ok(&["chmod", "700", &backup]).await?;
|
||||
for f in &files {
|
||||
sudo_ok(&["cp", "-a", f, &backup])
|
||||
.await
|
||||
.with_context(|| format!("backing up {f} — aborting before any deletion"))?;
|
||||
}
|
||||
let backed_up = sudo(&["find", &backup, "-maxdepth", "1", "-type", "f"])
|
||||
.await?
|
||||
.stdout;
|
||||
let backed_up = String::from_utf8_lossy(&backed_up)
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.count();
|
||||
if backed_up != files.len() {
|
||||
with_progress(|p| p.set("backup", StepState::Failed, None));
|
||||
anyhow::bail!(
|
||||
"backup incomplete — {backed_up} of {} files in {backup}. Refusing to delete anything",
|
||||
files.len()
|
||||
);
|
||||
}
|
||||
with_progress(|p| {
|
||||
p.backup_path = Some(backup.clone());
|
||||
p.set(
|
||||
"backup",
|
||||
StepState::Done,
|
||||
Some(format!("{backed_up} file(s) copied to {backup}")),
|
||||
);
|
||||
});
|
||||
|
||||
// 3. Stop. Nothing may restart LND from here until step 5 — see the lock
|
||||
// `run_rotation` holds around this whole function.
|
||||
with_progress(|p| p.set("stop", StepState::Running, None));
|
||||
stop_lnd().await.context("stopping LND")?;
|
||||
with_progress(|p| p.set("stop", StepState::Done, None));
|
||||
|
||||
// 4. Remove the credential material — and only now, with a verified backup.
|
||||
with_progress(|p| p.set("remove", StepState::Running, None));
|
||||
for f in &files {
|
||||
sudo_ok(&["rm", "-f", f])
|
||||
.await
|
||||
.with_context(|| format!("removing {f} — restore from {backup}"))?;
|
||||
}
|
||||
with_progress(|p| {
|
||||
p.set(
|
||||
"remove",
|
||||
StepState::Done,
|
||||
Some(format!("{} file(s) removed", files.len())),
|
||||
)
|
||||
});
|
||||
|
||||
// 5. Start, and unlock. The unlock is explicit rather than left to the next
|
||||
// reconcile tick: LND does not mint macaroons until the wallet opens, so
|
||||
// without this the rotation would sit waiting for a file that cannot
|
||||
// appear. `_no_wipe` keeps the destructive recovery path out of reach.
|
||||
with_progress(|p| p.set("start", StepState::Running, None));
|
||||
start_lnd().await.with_context(|| {
|
||||
format!("starting LND after removing its macaroons — old material is in {backup}")
|
||||
})?;
|
||||
crate::container::lnd::unlock_existing_wallet_no_wipe()
|
||||
.await
|
||||
.with_context(|| format!("unlocking the wallet — old material is in {backup}"))?;
|
||||
|
||||
let mint_deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(MACAROON_WAIT_SECS);
|
||||
let admin_path = format!("{LND_MAINNET_DIR}/admin.macaroon");
|
||||
let mut new_digest = None;
|
||||
while std::time::Instant::now() < mint_deadline {
|
||||
if let Some(d) = digest_as_root(&admin_path).await {
|
||||
new_digest = Some(d);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
let Some(new_digest) = new_digest else {
|
||||
with_progress(|p| p.set("start", StepState::Failed, None));
|
||||
anyhow::bail!(
|
||||
"LND did not mint a new admin.macaroon within {} minutes. The old material is intact \
|
||||
in {backup} — restore it there and investigate before retrying",
|
||||
MACAROON_WAIT_SECS / 60
|
||||
);
|
||||
};
|
||||
with_progress(|p| {
|
||||
p.new_admin_macaroon_sha256 = Some(new_digest.clone());
|
||||
p.set(
|
||||
"start",
|
||||
StepState::Done,
|
||||
Some("Lightning is up with freshly minted credentials".into()),
|
||||
);
|
||||
});
|
||||
|
||||
// 6. Verify the things that must NOT have changed.
|
||||
//
|
||||
// Its own budget, deliberately not the mint deadline. Sharing one would mean
|
||||
// a rotation that legitimately spent 14 of its 15 minutes waiting for LND to
|
||||
// mint gets 1 minute to prove the channels came back, and then reports
|
||||
// FAILURE on a node that is perfectly healthy — the most alarming possible
|
||||
// way to be wrong about someone's Lightning wallet.
|
||||
with_progress(|p| p.set("verify", StepState::Running, None));
|
||||
let verify_deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(MACAROON_WAIT_SECS);
|
||||
let (pubkey_after, census_after) =
|
||||
wait_for_serving(verify_deadline).await.with_context(|| {
|
||||
format!("verifying the node after rotation — old material is in {backup}")
|
||||
})?;
|
||||
with_progress(|p| p.channels_after = Some(census_after.channels_open));
|
||||
if pubkey_after != pubkey_before {
|
||||
with_progress(|p| p.set("verify", StepState::Failed, None));
|
||||
anyhow::bail!(
|
||||
"NODE IDENTITY CHANGED — this is not the same Lightning node. Old material is in \
|
||||
{backup}. Do not use this node until you understand why"
|
||||
);
|
||||
}
|
||||
if census_after.channels_open != census_before.channels_open
|
||||
|| census_after.channels_pending != census_before.channels_pending
|
||||
{
|
||||
with_progress(|p| p.set("verify", StepState::Failed, None));
|
||||
anyhow::bail!(
|
||||
"channel count changed ({} open/{} pending before, {} open/{} pending after). Old \
|
||||
material is in {backup}",
|
||||
census_before.channels_open,
|
||||
census_before.channels_pending,
|
||||
census_after.channels_open,
|
||||
census_after.channels_pending
|
||||
);
|
||||
}
|
||||
with_progress(|p| {
|
||||
p.set(
|
||||
"verify",
|
||||
StepState::Done,
|
||||
Some(format!(
|
||||
"same node, same {} channel(s)",
|
||||
census_after.channels_open
|
||||
)),
|
||||
)
|
||||
});
|
||||
|
||||
// 7. BTCPay. Its connection string embeds the macaroon inline and cannot
|
||||
// self-heal — see `rewrite_btcpay_lnd_connection_secret`. Left undone,
|
||||
// the node looks healthy while every Lightning invoice BTCPay creates
|
||||
// fails, which is precisely the failure this step exists to prevent.
|
||||
with_progress(|p| p.set("btcpay", StepState::Running, None));
|
||||
match crate::container::lnd::rewrite_btcpay_lnd_connection_secret(std::path::Path::new(
|
||||
SECRETS_DIR,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
// Writing the secret is only half of it. btcpay-server is
|
||||
// restart-sensitive, so reconcile sees the drift and deliberately
|
||||
// leaves the running container alone — which would strand it on the
|
||||
// dead macaroon indefinitely. This is the flag that overrides that,
|
||||
// and without it this whole step is cosmetic.
|
||||
match &orchestrator {
|
||||
Some(orch) => {
|
||||
orch.mark_credential_rotated("btcpay-server").await;
|
||||
with_progress(|p| {
|
||||
p.set(
|
||||
"btcpay",
|
||||
StepState::Done,
|
||||
Some(
|
||||
"Connection string updated. BTCPay restarts itself within a \
|
||||
minute or two to pick it up."
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
// Only reachable in builds without an orchestrator (tests). Say
|
||||
// what is left for a human rather than implying it is handled.
|
||||
None => with_progress(|p| {
|
||||
p.set(
|
||||
"btcpay",
|
||||
StepState::Skipped,
|
||||
Some(
|
||||
"Connection string updated, but no orchestrator is available to \
|
||||
restart BTCPay — restart it yourself to pick up the new credentials."
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(false) => with_progress(|p| {
|
||||
p.set(
|
||||
"btcpay",
|
||||
StepState::Skipped,
|
||||
Some("No internal Lightning node is configured for BTCPay on this node.".into()),
|
||||
)
|
||||
}),
|
||||
// Not fatal: the macaroons ARE rotated by this point, and reporting the
|
||||
// whole rotation as failed would be a lie that invites a needless retry.
|
||||
// Say exactly what is left undone instead.
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "btcpay connection string not updated after macaroon rotation");
|
||||
with_progress(|p| {
|
||||
p.set(
|
||||
"btcpay",
|
||||
StepState::Failed,
|
||||
Some(format!(
|
||||
"Your macaroons ARE rotated, but BTCPay's stored copy could not be \
|
||||
updated, so its Lightning payments will fail until it is: {e:#}"
|
||||
)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn progress_starts_with_every_step_pending() {
|
||||
let p = RotationProgress::default();
|
||||
assert_eq!(p.steps.len(), STEPS.len());
|
||||
assert!(p.steps.iter().all(|s| s.state == StepState::Pending));
|
||||
assert!(!p.running);
|
||||
assert!(p.ok.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_updates_only_the_named_step() {
|
||||
let mut p = RotationProgress::default();
|
||||
p.set("stop", StepState::Done, Some("stopped".into()));
|
||||
let stop = p.steps.iter().find(|s| s.key == "stop").unwrap();
|
||||
assert_eq!(stop.state, StepState::Done);
|
||||
assert_eq!(stop.detail.as_deref(), Some("stopped"));
|
||||
assert!(p
|
||||
.steps
|
||||
.iter()
|
||||
.filter(|s| s.key != "stop")
|
||||
.all(|s| s.state == StepState::Pending));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_on_an_unknown_step_is_a_no_op_not_a_panic() {
|
||||
let mut p = RotationProgress::default();
|
||||
p.set("not-a-step", StepState::Failed, None);
|
||||
assert!(p.steps.iter().all(|s| s.state == StepState::Pending));
|
||||
}
|
||||
|
||||
/// A detail is informational; passing `None` must not wipe one already set,
|
||||
/// or a later state transition would erase the explanation the operator is
|
||||
/// reading.
|
||||
#[test]
|
||||
fn set_without_a_detail_keeps_the_existing_one() {
|
||||
let mut p = RotationProgress::default();
|
||||
p.set("btcpay", StepState::Running, Some("working".into()));
|
||||
p.set("btcpay", StepState::Done, None);
|
||||
let step = p.steps.iter().find(|s| s.key == "btcpay").unwrap();
|
||||
assert_eq!(step.state, StepState::Done);
|
||||
assert_eq!(step.detail.as_deref(), Some("working"));
|
||||
}
|
||||
|
||||
/// The serialized shape is a UI contract: the frontend renders `state`
|
||||
/// as a lowercase discriminant.
|
||||
#[test]
|
||||
fn step_states_serialize_lowercase() {
|
||||
let json = serde_json::to_string(&StepState::Skipped).unwrap();
|
||||
assert_eq!(json, "\"skipped\"");
|
||||
}
|
||||
|
||||
/// Macaroon *content* must never reach the progress feed the UI polls.
|
||||
#[test]
|
||||
fn progress_carries_digests_not_tokens() {
|
||||
let mut p = RotationProgress::default();
|
||||
p.new_admin_macaroon_sha256 = Some("a".repeat(64));
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains("new_admin_macaroon_sha256"));
|
||||
assert!(!json.contains("macaroon_hex"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
mod channels;
|
||||
mod info;
|
||||
mod macaroons;
|
||||
mod payments;
|
||||
mod seed_backup;
|
||||
mod wallet;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// Canonical on-host path for LND's admin macaroon.
|
||||
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
|
||||
// Shared LND response types used by multiple submodules
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(super) struct LndBalanceResponse {
|
||||
pub total_balance: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(super) struct LndAmount {
|
||||
pub sat: Option<String>,
|
||||
}
|
||||
|
||||
/// Read LND's admin macaroon from disk.
|
||||
///
|
||||
/// The macaroon lives inside LND's container data dir and is owned by a
|
||||
/// rootless-podman subordinate UID (typically 100000), mode 640. The
|
||||
/// archipelago server runs as UID 1000 and therefore cannot read it
|
||||
/// directly. We first try a plain read (works if an operator has relaxed
|
||||
/// permissions), then fall back to `sudo cat` — mirroring the pattern
|
||||
/// already used for Tor hidden-service hostnames.
|
||||
pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
|
||||
match tokio::fs::read(LND_ADMIN_MACAROON_PATH).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(direct_err) => {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["-n", "cat", LND_ADMIN_MACAROON_PATH])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read LND admin macaroon (direct: {direct_err}); sudo fallback also failed"
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"Failed to read LND admin macaroon — is LND installed? (direct: {direct_err}; sudo: {})",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time wallet push (user req 2026-07-22): the UI must reflect an
|
||||
/// incoming on-chain transaction the moment the node sees it, not on the
|
||||
/// next poll. Streams LND's `/v1/transactions/subscribe` — it fires on
|
||||
/// 0-conf mempool arrival AND again on each confirmation — and nudges the
|
||||
/// shared data-model revision on every event; /ws/db pushes that to every
|
||||
/// connected client and the frontend refetches wallet balance/transactions.
|
||||
/// Reconnects forever with capped backoff: LND restarting, wallet locked, or
|
||||
/// LND not installed yet all just mean "try again shortly".
|
||||
pub(crate) fn spawn_lnd_tx_watcher(state_manager: std::sync::Arc<crate::state::StateManager>) {
|
||||
tokio::spawn(async move {
|
||||
let mut delay = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
match stream_lnd_transactions(&state_manager).await {
|
||||
// Stream ended cleanly (LND shutdown) — resume fast.
|
||||
Ok(()) => delay = std::time::Duration::from_secs(5),
|
||||
Err(e) => {
|
||||
tracing::debug!("lnd tx watcher: {e:#} — retrying in {delay:?}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(std::time::Duration::from_secs(120));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> {
|
||||
let macaroon_hex = hex::encode(read_lnd_admin_macaroon().await?);
|
||||
// Dedicated client: the shared lnd_client() carries a 15s total timeout,
|
||||
// which would kill this deliberately long-lived stream.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
let mut resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/subscribe"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("subscribe request failed")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"transactions/subscribe returned {}",
|
||||
resp.status()
|
||||
);
|
||||
tracing::info!("lnd tx watcher: streaming wallet transaction events");
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any streamed event = wallet activity. Revision bump is the push
|
||||
// contract (same pattern as the mesh-peer bridge in server.rs) — the
|
||||
// clients refetch, so we don't need to parse the event body.
|
||||
let (data, _) = sm.get_snapshot().await;
|
||||
sm.update_data(data).await;
|
||||
tracing::debug!(
|
||||
bytes = chunk.len(),
|
||||
"lnd tx watcher: wallet tx event — nudged ws clients"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// LND wedge watchdog (2026-07-22, "100% uptime"): a test node's LND sat
|
||||
/// for 14 HOURS with its RPC answering but the server never finishing
|
||||
/// startup — synced_to_chain=false, zero peers, every channel inactive —
|
||||
/// and nothing noticed until a human tried to open a channel. The wedge
|
||||
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
|
||||
/// with channels that need a peer) persists. A restart reliably clears it
|
||||
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
|
||||
/// after 15 consecutive bad minutes we bounce the container ourselves, with
|
||||
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
};
|
||||
let macaroon_hex = hex::encode(bytes);
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(resp) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
};
|
||||
let synced = info
|
||||
.get("synced_to_chain")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let channels = info
|
||||
.get("num_active_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_inactive_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
bad_minutes += 1;
|
||||
if bad_minutes < 15 {
|
||||
continue;
|
||||
}
|
||||
if last_restart
|
||||
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
synced_to_chain = synced,
|
||||
num_peers = peers,
|
||||
channels,
|
||||
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["restart", "lnd"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!("LND watchdog restart complete");
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
"LND watchdog restart failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
|
||||
}
|
||||
last_restart = Some(tokio::time::Instant::now());
|
||||
bad_minutes = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
/// hex-encoded admin macaroon for request headers.
|
||||
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_request = params
|
||||
.get("payment_request")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
||||
|
||||
// Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt
|
||||
if payment_request.len() < 10 || payment_request.len() > 2048 {
|
||||
return Err(anyhow::anyhow!("Invalid payment request length"));
|
||||
}
|
||||
let lower = payment_request.to_lowercase();
|
||||
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid payment request: must be a Lightning invoice (lnbc...)"
|
||||
));
|
||||
}
|
||||
|
||||
// Zero-amount invoices need the amount supplied by the payer; LND's
|
||||
// REST API takes it as an `amt` string alongside the payment request.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
|
||||
info!("Paying Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// Decode the invoice up front (fast, local) so we know its payment
|
||||
// hash BEFORE handing it to LND. If the payment outlives our wait
|
||||
// below, the hash is what lets the UI keep tracking it instead of
|
||||
// declaring a false failure. Best-effort: a decode hiccup must not
|
||||
// block the payment itself.
|
||||
let (decoded_hash, decoded_amt) = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r.json::<serde_json::Value>().await {
|
||||
Ok(d) => (
|
||||
d.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
d.get("num_satoshis")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
Err(_) => (String::new(), 0),
|
||||
},
|
||||
Err(_) => (String::new(), 0),
|
||||
};
|
||||
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
if let Some(amt) = amount_sats {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
||||
// payment settles or definitively fails, and multi-hop routing with
|
||||
// retries routinely takes longer than the shared client's 15s budget.
|
||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||
// paying in the background — only LND may declare a payment failed,
|
||||
// so a post-connect timeout is IN FLIGHT (status: pending), never
|
||||
// failure. The window is deliberately SHORT: most payments settle in
|
||||
// a couple of seconds and still get their answer in one round trip,
|
||||
// while a slow multi-hop route flips the UI into its "settling…"
|
||||
// polling state (lnd.paymentstatus every 3s) after ~8s instead of
|
||||
// freezing the modal for two minutes with no feedback (a test node
|
||||
// user report, 2026-07-29).
|
||||
let pay_client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let resp = match pay_client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_connect() => {
|
||||
// Never reached LND — nothing was sent; this IS a hard error.
|
||||
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
// Timed out (or lost the connection) AFTER the payment was
|
||||
// handed to LND — it may well still succeed. Report pending
|
||||
// with the hash so the caller can poll lnd.paymentstatus.
|
||||
info!("payinvoice wait elapsed; payment still in flight");
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"payment_hash": decoded_hash,
|
||||
"amount_sats": decoded_amt,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payment response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// Invoices are short-lived; retrying the same one can never
|
||||
// succeed, so tell the user the way out instead of just the fact.
|
||||
if msg.contains("invoice expired") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
msg.trim_start_matches("invoice expired. ")
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
let payment_error = body
|
||||
.get("payment_error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !payment_error.is_empty() {
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
||||
}
|
||||
|
||||
let amount_sat = body
|
||||
.get("payment_route")
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(decoded_amt);
|
||||
|
||||
let payment_hash = body
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(decoded_hash);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "succeeded",
|
||||
"payment_hash": payment_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
|
||||
/// "pending"`) to a real terminal state instead of guessing.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
|
||||
let hash_lower = payment_hash.to_lowercase();
|
||||
let found = body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|p| {
|
||||
p.get("payment_hash").and_then(|v| v.as_str()) == Some(hash_lower.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
let Some(p) = found else {
|
||||
// Not in the latest window — either very old or LND never saw it.
|
||||
return Ok(serde_json::json!({ "status": "unknown" }));
|
||||
};
|
||||
|
||||
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = match lnd_status {
|
||||
"SUCCEEDED" => "succeeded",
|
||||
"FAILED" => "failed",
|
||||
_ => "in_flight",
|
||||
};
|
||||
let failure_reason = match p
|
||||
.get("failure_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
{
|
||||
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
||||
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
||||
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
||||
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
||||
"Recipient rejected the payment (wrong details or expired invoice)"
|
||||
}
|
||||
"FAILURE_REASON_ERROR" => "Payment failed",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
fn amt(p: &serde_json::Value, key: &str) -> i64 {
|
||||
p.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"failure_reason": failure_reason,
|
||||
"amount_sats": amt(p, "value_sat"),
|
||||
"fee_sats": amt(p, "fee_sat"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse transactions response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to list transactions: {}", msg));
|
||||
}
|
||||
|
||||
let empty_vec = vec![];
|
||||
let raw_txs = body
|
||||
.get("transactions")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty_vec);
|
||||
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
for tx in raw_txs {
|
||||
let amount: i64 = tx
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("amount").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let num_confirmations: i64 = tx
|
||||
.get("num_confirmations")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let tx_hash = tx
|
||||
.get("tx_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let time_stamp: i64 = tx
|
||||
.get("time_stamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_fees: i64 = tx
|
||||
.get("total_fees")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| tx.get("total_fees").and_then(|v| v.as_i64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
let dest_addresses: Vec<String> = tx
|
||||
.get("dest_addresses")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let label = tx
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let block_height: i64 = tx.get("block_height").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": tx_hash,
|
||||
"amount_sats": amount.abs(),
|
||||
"direction": direction,
|
||||
"num_confirmations": num_confirmations,
|
||||
"time_stamp": time_stamp,
|
||||
"total_fees": total_fees,
|
||||
"dest_addresses": dest_addresses,
|
||||
"label": label,
|
||||
"block_height": block_height,
|
||||
}));
|
||||
}
|
||||
|
||||
// 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);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
let incoming_pending: usize = transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.get("direction").and_then(|v| v.as_str()) == Some("incoming")
|
||||
&& t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0)
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"transactions": transactions,
|
||||
"incoming_pending_count": incoming_pending,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Unified Lightning history: settled invoices (incoming) + succeeded
|
||||
/// payments (outgoing), normalized to the wallet-transaction shape the
|
||||
/// UI already renders. On-chain history stays in lnd.gettransactions.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_lightning_history(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
use base64::Engine;
|
||||
|
||||
fn field_i64(v: &serde_json::Value, key: &str) -> i64 {
|
||||
v.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| v.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let mut transactions: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Outgoing: succeeded payments only (include_incomplete=false)
|
||||
let payments_resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=false&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
if payments_resp.status().is_success() {
|
||||
let body: serde_json::Value = payments_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
for p in body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
{
|
||||
let amount = field_i64(p, "value_sat");
|
||||
if amount == 0 {
|
||||
continue;
|
||||
}
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": p.get("payment_hash").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"amount_sats": amount,
|
||||
"direction": "outgoing",
|
||||
"num_confirmations": 1,
|
||||
"time_stamp": field_i64(p, "creation_date"),
|
||||
"total_fees": field_i64(p, "fee_sat"),
|
||||
"dest_addresses": [],
|
||||
"label": "",
|
||||
"block_height": 0,
|
||||
"kind": "lightning",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming: settled invoices only
|
||||
let invoices_resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/invoices?num_max_invoices=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
if invoices_resp.status().is_success() {
|
||||
let body: serde_json::Value = invoices_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse invoices response")?;
|
||||
for inv in body
|
||||
.get("invoices")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&vec![])
|
||||
{
|
||||
let settled = inv.get("state").and_then(|v| v.as_str()) == Some("SETTLED")
|
||||
|| inv.get("settled").and_then(|v| v.as_bool()) == Some(true);
|
||||
if !settled {
|
||||
continue;
|
||||
}
|
||||
// r_hash arrives base64 from REST; the UI shows hex
|
||||
let r_hash_hex = inv
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(hex::encode)
|
||||
.unwrap_or_default();
|
||||
transactions.push(serde_json::json!({
|
||||
"tx_hash": r_hash_hex,
|
||||
"amount_sats": field_i64(inv, "amt_paid_sat"),
|
||||
"direction": "incoming",
|
||||
"num_confirmations": 1,
|
||||
"time_stamp": field_i64(inv, "settle_date"),
|
||||
"total_fees": 0,
|
||||
"dest_addresses": [],
|
||||
"label": inv.get("memo").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"block_height": 0,
|
||||
"kind": "lightning",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
tb.cmp(&ta)
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Encrypted LND aezeed backup: status, reveal, and acknowledgment.
|
||||
//!
|
||||
//! The aezeed is captured once at wallet-init time (see
|
||||
//! `crate::container::lnd::persist_aezeed_backup`) and stored under
|
||||
//! `identity/lnd_aezeed.enc`, encrypted with the per-node wallet secret.
|
||||
//! Reveal is gated like `seed.reveal`: authenticated session + password
|
||||
//! re-verification + TOTP when enabled.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Whether an encrypted aezeed backup exists and whether the user has
|
||||
/// confirmed writing it down. Drives the first-launch backup prompt.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let data_dir = &self.config.data_dir;
|
||||
Ok(serde_json::json!({
|
||||
"available": crate::seed::lnd_aezeed_exists(data_dir),
|
||||
"acknowledged": crate::seed::lnd_aezeed_acknowledged(data_dir),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reveal the Lightning wallet's 24 aezeed words. Same gating as
|
||||
/// `seed.reveal`; the words are returned to the caller only, never logged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
|
||||
if !crate::seed::lnd_aezeed_exists(&self.config.data_dir) {
|
||||
anyhow::bail!(
|
||||
"No Lightning seed backup exists on this node. It is captured \
|
||||
automatically when the Lightning wallet is first created."
|
||||
);
|
||||
}
|
||||
|
||||
let mut password = self
|
||||
.verify_reveal_auth(¶ms, "the Lightning seed")
|
||||
.await?;
|
||||
password.zeroize();
|
||||
|
||||
// The backup is encrypted with the per-node wallet secret (the boot
|
||||
// path has no user password), so re-auth above is the actual gate.
|
||||
let mut node_secret = crate::container::lnd::wallet_password_if_exists()
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved Lightning seed — the per-node \
|
||||
wallet secret is missing"
|
||||
)
|
||||
})?;
|
||||
let words =
|
||||
crate::seed::load_lnd_aezeed_encrypted(&self.config.data_dir, &node_secret).await;
|
||||
node_secret.zeroize();
|
||||
let words = words
|
||||
.map_err(|_| anyhow::anyhow!("Could not decrypt the saved Lightning seed backup"))?;
|
||||
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
}
|
||||
|
||||
/// Record that the user confirmed backing up the Lightning seed, which
|
||||
/// dismisses the first-launch prompt.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_seed_backup_ack(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
crate::seed::mark_lnd_aezeed_acknowledged(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "acknowledged": true }))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user