Merge main into archy-hwconfig — reconcile probe/dedup/name work

Both sides independently fixed the serial-alias dedup and the ESP32
boot-reset races; kept the branch's defer-to-auto-detect for unpinned
preferred paths (single probe pass per cycle) on top of main's
advert-name threading, Reticulum name propagation and radio-first
routing. Modal keeps main's 'Set Recommended' naming + probe progress
bar alongside the branch's in-app firmware flasher step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-28 19:03:19 -04:00
co-authored by Claude Fable 5
173 changed files with 10967 additions and 1925 deletions
+9 -1
View File
@@ -106,7 +106,7 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let name = params
let mut name = params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
@@ -116,6 +116,14 @@ impl RpcHandler {
if name.is_empty() || name.len() > 64 {
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
}
// The default name was a single shared slot: every pairing popup
// replaced the previous phone's token, silently logging out the
// first phone the moment a second one paired. Default-named mints
// get a unique suffix so each device keeps its own credential;
// explicitly named devices keep replace-in-place semantics.
if name == "companion" {
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
}
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
Ok(serde_json::json!({ "name": name, "token": token }))
}
@@ -465,6 +465,7 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&peer.pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
}
+12 -12
View File
@@ -279,6 +279,7 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.send_get()
.await
.context("Failed to connect to peer")?;
@@ -364,6 +365,11 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
// The Cloud page's hottest call: without a fast-fail cap a
// cold FIPS path burned ~16.6s before Tor even started,
// against the UI's 30s deadline — users saw errors, not
// fallback.
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
.context("Failed to connect to peer")?;
@@ -443,8 +449,7 @@ impl RpcHandler {
&& (o.content_id == content_id
|| filename.is_some_and(|f| {
!f.is_empty()
&& o.filename.trim_start_matches('/')
== f.trim_start_matches('/')
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
}))
});
if let Some(o) = already {
@@ -454,12 +459,9 @@ impl RpcHandler {
owned_as = %o.content_id,
"paid download: already owned — serving cached copy, NOT paying again"
);
if let Some((mime, bytes)) = crate::content_owned::read_owned(
&self.config.data_dir,
&o.onion,
&o.content_id,
)
.await
if let Some((mime, bytes)) =
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
.await
{
use base64::Engine;
return Ok(serde_json::json!({
@@ -692,10 +694,7 @@ impl RpcHandler {
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!(
"paid download: filed into {}",
target.display()
),
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
@@ -1144,6 +1143,7 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
.context("Failed to connect to peer for preview")?;
@@ -124,12 +124,15 @@ impl RpcHandler {
}
"lnd.getinfo" => self.handle_lnd_getinfo().await,
"lnd.listchannels" => self.handle_lnd_listchannels().await,
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
"lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
@@ -393,6 +396,7 @@ impl RpcHandler {
"mesh.flash-status" => self.handle_mesh_flash_status().await,
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.refresh" => self.handle_mesh_refresh().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
"mesh.send" => self.handle_mesh_send(params).await,
@@ -865,7 +865,9 @@ impl RpcHandler {
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30));
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(&self.config.data_dir);
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {
+15 -5
View File
@@ -13,7 +13,14 @@ use anyhow::Result;
impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
let mut v = serde_json::to_value(status)?;
// Dial outcome counters (process-lifetime): how often peer dials
// used FIPS vs fell back to Tor, broken down by reason. This is
// the observability that makes "FIPS uptime" measurable.
if let Some(obj) = v.as_object_mut() {
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
}
Ok(v)
}
/// Everything the companion app needs to join this node's mesh, embedded
@@ -23,9 +30,11 @@ impl RpcHandler {
/// host/IP itself — it knows which origin the browser reached the node on.
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
let npub = crate::identity::fips_npub(&identity_dir).await?.ok_or_else(|| {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?;
let npub = crate::identity::fips_npub(&identity_dir)
.await?
.ok_or_else(|| {
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
})?;
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
// The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint
@@ -81,7 +90,8 @@ impl RpcHandler {
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
fips::config::install(&identity_dir).await?;
fips::service::activate(fips::SERVICE_UNIT).await?;
let unit = fips::service::activation_unit().await;
fips::service::activate(unit).await?;
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
}
+187 -35
View File
@@ -30,6 +30,8 @@ struct ChannelInfo {
active: bool,
status: String,
channel_point: String,
#[serde(skip_serializing_if = "String::is_empty")]
closing_txid: String,
}
#[derive(Debug, Serialize)]
@@ -58,6 +60,10 @@ struct LndChannel {
#[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)]
@@ -65,6 +71,18 @@ 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>,
@@ -74,6 +92,52 @@ struct LndPendingChannel {
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?;
@@ -131,6 +195,7 @@ impl RpcHandler {
"inactive".into()
},
channel_point: ch.channel_point.unwrap_or_default(),
closing_txid: String::new(),
}
})
.collect();
@@ -138,31 +203,20 @@ impl RpcHandler {
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 {
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);
pending_channels.push(ChannelInfo {
chan_id: String::new(),
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
capacity,
local_balance: local,
remote_balance: remote,
active: false,
status: "pending_open".into(),
channel_point: ch.channel_point.unwrap_or_default(),
});
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));
}
}
@@ -349,6 +403,46 @@ impl RpcHandler {
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>,
@@ -389,27 +483,35 @@ impl RpcHandler {
"Closing Lightning channel"
);
let (client, macaroon_hex) = self.lnd_client().await?;
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 resp = client
let mut resp = client
.delete(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to close channel")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse close channel response")?;
if !status.is_success() {
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())
@@ -417,6 +519,56 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
}
Ok(serde_json::json!({ "success": true }))
// 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": "" })),
}
}
}
+150 -5
View File
@@ -36,6 +36,33 @@ impl RpcHandler {
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,
});
@@ -43,13 +70,46 @@ impl RpcHandler {
pay_body["amt"] = serde_json::json!(amt.to_string());
}
let resp = client
// `/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 — the payment then succeeded and appeared
// in history a minute later. Wait up to 120s on a dedicated client,
// and treat a post-connect timeout as IN FLIGHT (status: pending),
// never as failure — only LND may declare a payment failed.
let pay_client = reqwest::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.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
.context("Failed to pay invoice")?;
{
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
@@ -86,20 +146,105 @@ impl RpcHandler {
.and_then(|r| r.get("total_amt"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
.unwrap_or(decoded_amt);
let payment_hash = body
.get("payment_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
.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(
+109 -1
View File
@@ -124,16 +124,41 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
// Fee control: either a confirmation target or an explicit fee rate
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
if target_conf.is_some() && sat_per_vbyte.is_some() {
return Err(anyhow::anyhow!(
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
));
}
if let Some(tc) = target_conf {
if !(1..=1008).contains(&tc) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
}
if let Some(rate) = sat_per_vbyte {
if !(1..=5000).contains(&rate) {
return Err(anyhow::anyhow!(
"Invalid sat_per_vbyte: must be between 1 and 5000"
));
}
}
info!(
addr = addr,
amount = amount,
send_all = send_all,
target_conf = target_conf,
sat_per_vbyte = sat_per_vbyte,
"Sending on-chain Bitcoin"
);
let (client, macaroon_hex) = self.lnd_client().await?;
let send_body = match amount {
let mut send_body = match amount {
Some(amount) => serde_json::json!({
"addr": addr,
"amount": amount.to_string(),
@@ -143,6 +168,13 @@ impl RpcHandler {
"send_all": true,
}),
};
if let Some(tc) = target_conf {
send_body["target_conf"] = serde_json::json!(tc);
}
if let Some(rate) = sat_per_vbyte {
// LND REST encodes uint64 as a JSON string
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
}
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
@@ -171,6 +203,82 @@ impl RpcHandler {
Ok(serde_json::json!({ "txid": txid }))
}
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
/// UI can show the cost of each preset before the user confirms.
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let addr = params
.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
}
let amount = params
.get("amount")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
return Err(anyhow::anyhow!("Invalid amount"));
}
let target_conf = params
.get("target_conf")
.and_then(|v| v.as_i64())
.unwrap_or(6);
if !(1..=1008).contains(&target_conf) {
return Err(anyhow::anyhow!(
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
.query(&[
(format!("AddrToAmount[{addr}]"), amount.to_string()),
("target_conf".to_string(), target_conf.to_string()),
])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to estimate fee")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse fee estimate 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 estimate fee: {}", msg));
}
// LND REST encodes int64 as JSON strings
let fee_sat = body
.get("fee_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let sat_per_vbyte = body
.get("sat_per_vbyte")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
Ok(serde_json::json!({
"fee_sat": fee_sat,
"sat_per_vbyte": sat_per_vbyte,
}))
}
/// Create a Lightning invoice.
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
///
+27 -5
View File
@@ -1,6 +1,7 @@
use super::super::RpcHandler;
use crate::mesh;
use anyhow::Result;
use std::sync::Arc;
use tracing::info;
impl RpcHandler {
@@ -131,7 +132,14 @@ impl RpcHandler {
config.broadcast_identity = broadcast;
}
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
// Empty clears the custom mesh name (falls back to the server
// name) — without this, a name could be set but never unset.
let trimmed = name.trim();
config.advert_name = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
}
if let Some(announce) = params
.get("announce_block_headers")
@@ -202,10 +210,24 @@ impl RpcHandler {
mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config
let mut service = self.mesh_service.write().await;
if let Some(svc) = service.as_mut() {
svc.configure(config.clone()).await?;
// Apply to the running service in the background: configure() may
// stop+start the listener (config changes now restart the session so
// they actually take effect), and that can take seconds when the old
// session is mid-probe. Holding the service write-lock for that long
// inside this handler stalled every concurrent mesh.status/mesh.peers
// poll behind it — the UI froze and nginx surfaced 502s. The config is
// already persisted above; the UI observes progress via mesh.status.
{
let service_arc = Arc::clone(&self.mesh_service);
let config_for_apply = config.clone();
tokio::spawn(async move {
let mut service = service_arc.write().await;
if let Some(svc) = service.as_mut() {
if let Err(e) = svc.configure(config_for_apply).await {
tracing::error!("Applying mesh config to running service failed: {e:#}");
}
}
});
}
info!("Mesh config updated");
@@ -134,6 +134,28 @@ impl RpcHandler {
Ok(serde_json::to_value(probe)?)
}
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
/// contact table and re-announce ourselves so quiet-but-alive neighbours
/// answer. This is what the UI's Refresh button calls — before it existed
/// the button only re-read server caches and never touched the radio.
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let Some(svc) = service.as_ref() else {
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
};
let status = svc.status().await;
if status.device_connected {
let state = svc.shared_state();
let _ = state
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
.await;
}
Ok(serde_json::json!({
"refreshed": status.device_connected,
"device_connected": status.device_connected,
}))
}
/// mesh.peers — List discovered mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
@@ -820,6 +820,8 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
.service(crate::settings::transport::PeerService::MeshFileSharing)
.timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.record_transport(&self.config.data_dir)
.send_get()
.await
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
+2 -2
View File
@@ -560,8 +560,8 @@ impl RpcHandler {
.is_some(),
None => false,
};
let totp_enabled = !is_token_login
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false);
let totp_enabled =
!is_token_login && self.auth_manager.is_totp_enabled().await.unwrap_or(false);
if totp_enabled {
let password = login_params
.as_ref()
+2
View File
@@ -137,6 +137,7 @@ impl RpcHandler {
None,
None,
None,
Some(&self.config.data_dir),
)
.await?;
@@ -225,6 +226,7 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&req.from_pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
{
@@ -2324,17 +2324,28 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
}
async fn cleanup_stale_pasta_port(port: &str) {
// NEVER kill our own process. The daemon holds catalog app ports over
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
// terminate archipelago itself mid-install — installs failed and apps
// vanished on framework-pt 2026-07-27. Kill every listener on the port
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
let self_pid = std::process::id();
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args(["fuser", "-k", &format!("{}/tcp", port)])
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
let fuser_kill = format!(
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &fuser_kill])
.output()
.await;
@@ -766,7 +766,11 @@ async fn find_satellite(port: u16) -> Option<String> {
if self_ips.contains(&ip) {
continue;
}
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
set.spawn(async move {
tcp_alive(&ip.to_string(), port, 500)
.await
.then(|| ip.to_string())
});
}
while let Some(res) = set.join_next().await {
if let Ok(Some(ip)) = res {
+1
View File
@@ -133,6 +133,7 @@ impl RpcHandler {
Some(node_id.signing_key()),
recipient_pubkey.as_deref(),
node_name.as_deref(),
Some(&self.config.data_dir),
)
.await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
+12 -11
View File
@@ -56,12 +56,12 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
Ok(true)
}
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
/// seed onboarding has written the fips_key to disk. Runs in a detached task
/// so the user-facing RPC returns immediately — the systemctl calls can take
/// a few seconds the first time on slow hardware. Any failure is logged but
/// does not break onboarding; the user can still hit fips.install manually
/// from the dashboard as an escape hatch.
/// Best-effort: install fips.yaml + start the available FIPS systemd unit after
/// seed onboarding has written the fips_key to disk. Runs in a detached task so
/// the user-facing RPC returns immediately — the systemctl calls can take a few
/// seconds the first time on slow hardware. Any failure is logged but does not
/// break onboarding; the user can still hit fips.install manually from the
/// dashboard as an escape hatch.
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
let identity_dir = data_dir.join("identity");
@@ -78,11 +78,12 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tracing::warn!("post-onboarding fips config install failed: {}", e);
return;
}
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
let unit = crate::fips::service::activation_unit().await;
if let Err(e) = crate::fips::service::activate(unit).await {
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
return;
}
tracing::info!("archipelago-fips auto-activated post-onboarding");
tracing::info!("FIPS auto-activated post-onboarding via {}", unit);
});
}
@@ -138,8 +139,8 @@ impl RpcHandler {
// Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// fips_key is now on disk — auto-activate archipelago-fips so the
// user doesn't have to hit an "Activate" button. Detached task;
// fips_key is now on disk — auto-activate FIPS so the user doesn't
// have to hit a manual Start button. Detached task;
// the onboarding RPC returns immediately.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
@@ -61,6 +61,28 @@ impl RpcHandler {
info!("Server name updated to: {}", name);
// Propagate to the mesh: the listener advertises the server name (when
// no explicit mesh advert_name overrides it), but it was only read at
// process startup — a rename never reached the radio/RNS until the
// next full restart. Push it into the service and bounce the listener
// in the background (the restart re-probes the radio, which can take
// seconds — don't block the rename response on it).
{
let mesh_arc = self.mesh_service_arc();
let name_for_mesh = name.clone();
tokio::spawn(async move {
let mut guard = mesh_arc.write().await;
if let Some(svc) = guard.as_mut() {
svc.set_server_name(Some(name_for_mesh));
if svc.config().advert_name.is_none() {
if let Err(e) = svc.restart_listener_if_running().await {
warn!("Mesh listener restart after rename failed: {}", e);
}
}
}
});
}
// Push the new name to federation peers in background
let data_dir = self.config.data_dir.clone();
let state_manager = self.state_manager.clone();
+3 -1
View File
@@ -498,7 +498,9 @@ pub(super) async fn notify_federation_peers_address_change(
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30));
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir);
match req.send_json(&payload).await {
Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")