feat(01-04): the two Lightning facts the channel-open picker needs (FED-05)
Tasks 1 and 2 of 01-04. This node's own shareable URI, and a mesh message a peer uses to advertise theirs. lnd.getinfo now deserializes identity_pubkey and uris, which its response struct simply did not declare before (RESEARCH.md Pitfall 5). The identity mapping is split into a pure map_identity() so it is testable without a live LND. A pubkey that is not 66 hex characters maps to None rather than being forwarded: the same rule lnd.openchannel enforces, applied where the operator is reading their own node's identity instead of at the moment they try to open a channel. An absent field yields an honest absence — never a fabricated or placeholder identity. MeshMessageType::LightningInfo = 26 is additive on a wire format shared with every fleet node: 26 was unused, so a peer that predates this fails to decode it rather than mis-decoding it as something else. Its payload is deliberately two fields — this rides LoRa, where every byte is paid for on air, and the optional alias is skip_serializing_if so an absent one costs nothing (asserted, not assumed). is_valid_lightning_uri() validates before anything is stored, because this is unauthenticated RF input: 66-hex pubkey, non-empty host, optional numeric :port, exactly one '@'. It deliberately does NOT resolve or dial the host — that would turn a received advertisement into an outbound connection an attacker chose. Two preservation hazards found while wiring MeshPeer.lightning_uri, both of which would have silently emptied the picker: - decode.rs's identity-advert path does a WHOLESALE insert, preserving only advert_name and lat/lon by hand. Reticulum re-emits identity adverts every announce tick, so a stored URI would have been wiped about once a minute. Now preserved, alongside the same guard the name and position already had. - session.rs's refresh_contacts and mod.rs's federation seeding rebuild the peer record wholesale too. Neither carries a Lightning datum, so both now carry the previous value forward rather than nulling it. A malformed inbound URI is rejected before the write, leaving any previously stored good URI intact — otherwise anyone in range could blank out a real peer's picker entry (T-01-12). Verified: 5/5 new lnd::info tests, 18/18 mesh::message_types (5 new), cargo check --all-targets clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e205f2c34a
commit
decb7c713b
@@ -15,6 +15,13 @@ struct LndInfo {
|
||||
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)]
|
||||
@@ -24,6 +31,40 @@ struct LndGetInfoResponse {
|
||||
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)]
|
||||
@@ -84,7 +125,11 @@ impl RpcHandler {
|
||||
},
|
||||
};
|
||||
|
||||
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),
|
||||
@@ -218,3 +263,78 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user