Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -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": "" })),
}
}
}
+343
View File
@@ -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"));
}
}
+241
View File
@@ -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(&params, "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