From 666990c684bc53f27ff19a35d509046dd950c28f Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 22:48:02 -0400 Subject: [PATCH] feat(01-04): expose meshed Lightning peers and the send path over RPC (FED-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3, completing 01-04. mesh.lightning-peers returns the peers that have advertised a Lightning URI: filtered, deduplicated, deterministically ordered, and an empty array rather than an error when nobody has — "nobody yet" is a normal state on a fresh node, not a fault. mesh.send-lightning-info advertises this node's own URI to ONE chosen peer. There is deliberately no broadcast form: this discloses the node's payment endpoint, and who learns it is the operator's choice rather than a side effect of being in radio range (T-01-13). It refuses to send when LND advertises no URI, instead of sending an empty one a peer would store as an undialable target. The list-building and target-parsing logic is extracted into pure functions because this file has no handler test harness and the handlers need a live mesh service. That keeps the three contracts that actually matter provable rather than merely readable: - dedup is keyed on identity_pubkey_hex() — the AUTHENTICATING key, lowercased — never the firmware routing key, so a radio contact and its federation twin collapse to one entry (T-01-11) - "newest advertisement wins" compares PARSED RFC3339 timestamps, not strings: 09:30-01:00 is later than 10:00Z while sorting earlier as text, and there is a test that fails if that is ever string-compared - ordering is name-then-contact_id and asserted byte-identical across eight rotations of the input, because a HashMap's iteration order is not stable and a picker that reshuffles between reads means an operator can click a different node than the one they aimed at The peer allow-list is untouched: server.rs has an empty diff and is_peer_allowed_path still occurs 13 times (T-01-15). Verified: cargo test -p archipelago 1087 passed / 0 failed; clippy --all-targets clean in every touched module (two useless_format lints in the new test code fixed, not waived). The SUMMARY records one deviation honestly: Task 1's tests were written alongside its implementation rather than before, so no pre-implementation failing output exists. A mutation test was run in its place — disabling the pubkey validation fails 3 of the 5 tests — which proves the assertions bind, and the mutation was reverted and verified gone. Co-Authored-By: Claude Opus 5 (1M context) --- .../01-04-SUMMARY.md | 135 ++++++++ core/archipelago/src/api/rpc/dispatcher.rs | 2 + .../src/api/rpc/mesh/typed_messages.rs | 294 ++++++++++++++++++ core/archipelago/src/mesh/message_types.rs | 4 +- 4 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md diff --git a/.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md new file mode 100644 index 00000000..b897a207 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md @@ -0,0 +1,135 @@ +--- +phase: 01-federation-mesh-hardening +plan: 04 +subsystem: mesh +tags: [lightning, mesh, FED-05, typed-envelope, channel-open] +status: complete +requires: + - "01-CONTEXT.md's LOCKED FED-05 scope: the picker's public/other list is meshed peers that have Lightning installed" + - "RESEARCH.md Pitfall 5 — neither datum existed; PATTERNS.md — peer capability advertisement has no analog" +provides: + - "lnd.getinfo carries identity_pubkey + uris (or an honest absence)" + - "MeshMessageType::LightningInfo = 26 + LightningInfoPayload + is_valid_lightning_uri()" + - "MeshPeer.lightning_uri, populated only by an explicit advertisement" + - "mesh.lightning-peers (deduplicated, deterministically ordered) and mesh.send-lightning-info (target required)" +affects: + - "core/archipelago/src/api/rpc/lnd/info.rs" + - "core/archipelago/src/mesh/message_types.rs" + - "core/archipelago/src/mesh/types.rs" + - "core/archipelago/src/mesh/listener/dispatch.rs" + - "core/archipelago/src/mesh/listener/decode.rs (unplanned — see Deviations)" + - "core/archipelago/src/mesh/listener/session.rs (unplanned — see Deviations)" + - "core/archipelago/src/mesh/mod.rs (unplanned — see Deviations)" + - "core/archipelago/src/api/rpc/mesh/typed_messages.rs" + - "core/archipelago/src/api/rpc/dispatcher.rs" +tech-stack: + added: [] + patterns: + - "Extract a pure function at the seam so a contract is testable without a live service (map_identity, build_lightning_peer_list, parse_send_lightning_target) — the same shape Task 1's plan prescribed, reused for Task 3 where no handler test harness exists" + - "Validate unauthenticated RF input BEFORE touching stored state, so a malformed message cannot destroy a good prior value" + - "Mutation testing as evidence that tests are load-bearing, where pre-implementation failure output was not captured" +key-files: + created: [] + modified: + - "core/archipelago/src/api/rpc/lnd/info.rs" + - "core/archipelago/src/mesh/message_types.rs" + - "core/archipelago/src/mesh/types.rs" + - "core/archipelago/src/mesh/listener/dispatch.rs" + - "core/archipelago/src/mesh/listener/decode.rs" + - "core/archipelago/src/mesh/listener/session.rs" + - "core/archipelago/src/mesh/mod.rs" + - "core/archipelago/src/api/rpc/mesh/typed_messages.rs" + - "core/archipelago/src/api/rpc/dispatcher.rs" +decisions: + - "is_valid_lightning_uri deliberately does NOT resolve or dial the host — that would turn a received advertisement into an outbound connection an attacker chose" + - "Dedup keys on identity_pubkey_hex() (the authenticating key), lowercased, never the firmware routing key — T-01-11" + - "last_heard compared as a parsed RFC3339 timestamp, not as a string, so a differing UTC offset cannot misorder 'newest wins'" + - "Three unplanned files were touched: all three rebuild MeshPeer wholesale and would have silently wiped lightning_uri" +requirements-completed: [] +metrics: + duration: "~1h" + completed: 2026-08-02 + tasks_completed: 3 + tasks_total: 3 +--- + +# 01-04 — the two Lightning facts the channel-open picker needs (FED-05) + +## What shipped + +| Task | Delivered | +|---|---| +| 1 | `lnd.getinfo` deserializes and returns `identity_pubkey` + `uris`; a pubkey that is not 66 hex chars maps to `None` rather than being forwarded | +| 2 | `MeshMessageType::LightningInfo = 26`, `LightningInfoPayload { uri, alias? }`, `is_valid_lightning_uri()`, `MeshPeer.lightning_uri`, and a validating inbound dispatch arm | +| 3 | `mesh.lightning-peers` (filtered, deduplicated, stable-ordered) and `mesh.send-lightning-info` (explicit target required), both registered in the dispatcher | + +## The part that was not in the plan, and mattered most + +`MeshPeer.lightning_uri` was specified as a field addition. It is, but **three separate code +paths rebuild a `MeshPeer` wholesale**, and every one of them would have silently discarded the +new field: + +1. **`listener/decode.rs` — the identity-advert path.** A wholesale `peers.insert()` that + hand-preserves only `advert_name` and `lat`/`lon`. Its own comment records why those two are + there: Reticulum "re-emits identity adverts every announce tick", which had previously been + renaming every federated contact once a minute. A stored Lightning URI would have been erased + on the same schedule. +2. **`listener/session.rs` — `refresh_contacts`.** Rebuilds the record from the radio snapshot, + which carries no Lightning datum. +3. **`mesh/mod.rs` — federation seeding.** Same shape. + +All three now carry the previous value forward. Without this the feature would have appeared to +work in tests and quietly emptied the picker on a live node — the failure mode is an absence, +which is exactly the kind that does not announce itself. + +## Security posture + +- **T-01-12 (tampering):** the inbound arm validates the URI *before* the write and returns + early on failure, so a malformed advertisement from anyone in range cannot blank out a real + peer's entry. Asserted by test, not just by reading. +- **T-01-13 (disclosure):** `mesh.send-lightning-info` requires an explicit `contact_id`. There + is no broadcast form, and the test asserts that `{}`, `{"broadcast": true}` and an + out-of-range id are all refused rather than treated as "send to everyone". +- **T-01-11 (spoofing):** dedup keys on the authenticating key, never the firmware routing key. +- **T-01-15 (EoP):** `server.rs` is untouched — `git diff HEAD` on it is empty, and + `is_peer_allowed_path` still occurs 13 times. The peer allow-list was not widened. +- Wire compatibility: discriminant 26 was unused, so a node predating this fails to decode the + message rather than mis-decoding it as another type. The optional `alias` is + `skip_serializing_if`, asserted to cost fewer bytes on air when absent — this rides LoRa. + +## Evidence + +- **`cargo test -p archipelago`: 1087 passed, 0 failed, 2 ignored.** +- New tests: 5 (`lnd::info`), 5 (`mesh::message_types`), 6 (`lightning_peer_tests`). +- `cargo clippy --all-targets`: **no warnings in any touched module** (two `useless_format` + lints in the new test code were fixed, not waived). +- Every acceptance-criteria grep met, including the negative one on `server.rs`. + +## Deviation: TDD ordering on Task 1 + +The plan required the SUMMARY to record "the pre-implementation failing output of the fixture +tests". Tests and implementation were written in the same pass, so **that output does not exist +and is not reproduced here.** + +Rather than drop the requirement's intent — *prove the tests are load-bearing* — a mutation test +was run in its place. `is_valid_identity_pubkey` was replaced with `true`, and the suite re-run: + +``` +3 failed: + api::rpc::lnd::info::tests::malformed_pubkey_is_dropped_rather_than_propagated + api::rpc::lnd::info::tests::a_malformed_pubkey_does_not_discard_the_advertised_uris + api::rpc::lnd::info::tests::valid_pubkey_shape_matches_the_openchannel_rule +``` + +The mutation was reverted and its absence verified. This is stronger evidence than a +pre-implementation red run (which only shows the code is absent, not that the assertions bind), +but it is a deviation from the ordering the plan asked for, and is recorded as one. + +## Open / handed on + +- **Nothing here is exercised on hardware yet.** Two archy nodes with LND and a radio link are + needed to see a real advertisement traverse the mesh; every claim above is unit-level. +- `mesh.lightning-peers` is a data source with no consumer until **01-06** builds the picker UI + (which `depends_on` this plan). +- `MeshPeer.lightning_uri`'s doc mentions federation seeding as a future source; this plan does + not implement it, and `mesh/mod.rs` only preserves an existing value. diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 0cc18c67..1ad48533 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -422,6 +422,8 @@ impl RpcHandler { "mesh.send-psbt" => self.handle_mesh_send_psbt(params).await, "mesh.broadcast-presence" => self.handle_mesh_broadcast_presence(params).await, "mesh.presence-list" => self.handle_mesh_presence_list(params).await, + "mesh.lightning-peers" => self.handle_mesh_lightning_peers(params).await, + "mesh.send-lightning-info" => self.handle_mesh_send_lightning_info(params).await, "mesh.contacts-list" => self.handle_mesh_contacts_list(params).await, "mesh.contacts-save" => self.handle_mesh_contacts_save(params).await, "mesh.contacts-block" => self.handle_mesh_contacts_block(params).await, diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 639d0dcc..a6cdfda6 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -1368,4 +1368,298 @@ impl RpcHandler { .await?; Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq })) } + + /// mesh.lightning-peers — the meshed peers that have advertised a Lightning + /// URI, i.e. the "public/other" side of the channel-open picker (FED-05). + /// + /// Returns an empty array, never an error, when no peer has advertised — + /// "nobody yet" is a normal state on a fresh node, not a fault. + pub(in crate::api::rpc) async fn handle_mesh_lightning_peers( + &self, + _params: Option, + ) -> Result { + let service = self.mesh_service.read().await; + let svc = service + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + let state = svc.shared_state(); + let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect(); + Ok(serde_json::json!({ "peers": build_lightning_peer_list(&peer_vec) })) + } + + /// mesh.send-lightning-info — advertise THIS node's Lightning URI to one + /// chosen peer. + /// + /// Requires an explicit target. There is deliberately no broadcast form: + /// this discloses the node's payment endpoint, and who learns it is the + /// operator's choice, not a side effect of being in radio range (T-01-13). + pub(in crate::api::rpc) async fn handle_mesh_send_lightning_info( + &self, + params: Option, + ) -> Result { + let contact_id = parse_send_lightning_target(params.as_ref())?; + + // Read our own URI from the lnd.getinfo path. Refuse rather than send an + // empty advertisement: a peer that stored "" would show us in its picker + // as a target it can never dial. + let info = self + .handle_lnd_getinfo() + .await + .map_err(|e| anyhow::anyhow!("Cannot read this node's Lightning info: {e}"))?; + let uri = info + .get("uris") + .and_then(|u| u.as_array()) + .and_then(|a| a.first()) + .and_then(|u| u.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + anyhow::anyhow!( + "This node has no advertised Lightning URI to share — LND may be down, or \ + configured with no externally reachable address" + ) + })?; + if !message_types::is_valid_lightning_uri(&uri) { + return Err(anyhow::anyhow!( + "This node's own Lightning URI is malformed; refusing to advertise it" + )); + } + let alias = info + .get("alias") + .and_then(|a| a.as_str()) + .filter(|a| !a.is_empty()) + .map(|a| a.to_string()); + + let payload_struct = message_types::LightningInfoPayload { uri, alias }; + + let service = self.mesh_service.read().await; + let svc = service + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + let seq = svc.next_send_seq(contact_id).await; + let payload = message_types::encode_payload(&payload_struct)?; + let envelope = TypedEnvelope::new(MeshMessageType::LightningInfo, payload).with_seq(seq); + let wire = envelope.to_wire()?; + let typed_json = serde_json::to_value(&payload_struct).ok(); + let msg = svc + .send_typed_wire( + contact_id, + wire, + "lightning_info", + "Shared Lightning connection info", + typed_json, + seq, + ) + .await?; + info!(contact_id, seq, "Sent lightning_info to a chosen mesh peer"); + Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq })) + } +} + +/// The required target for `mesh.send-lightning-info`. +/// +/// Split out so the "a target is mandatory" contract is testable without a mesh +/// service — that contract is the whole of T-01-13's mitigation, so it should +/// not be provable only by reading the code. +fn parse_send_lightning_target(params: Option<&serde_json::Value>) -> Result { + let params = + params.ok_or_else(|| anyhow::anyhow!("Missing params: a target contact_id is required"))?; + let contact_id = params["contact_id"].as_u64().ok_or_else(|| { + anyhow::anyhow!( + "Missing contact_id: mesh.send-lightning-info requires an explicit target and has no \ + broadcast form" + ) + })?; + u32::try_from(contact_id).map_err(|_| anyhow::anyhow!("contact_id out of range")) +} + +/// Build the deduplicated, deterministically ordered Lightning-peer list. +/// +/// Pure so the dedup and ordering contracts are testable without a mesh +/// service. Ordering matters for a real reason: the picker must not reshuffle +/// between reads, or an operator clicking a row can hit a different node than +/// the one they aimed at. +fn build_lightning_peer_list(peers: &[crate::mesh::types::MeshPeer]) -> Vec { + use std::collections::HashMap; + + // Dedup by the AUTHENTICATING key, never the firmware routing key: a radio + // contact and its federation twin are one node and must not be offered + // twice (T-01-11). Peers with no key at all fall back to contact_id, which + // is unique per record. + let mut best: HashMap = HashMap::new(); + for peer in peers.iter().filter(|p| p.lightning_uri.is_some()) { + let key = peer + .identity_pubkey_hex() + .map(|k| k.to_ascii_lowercase()) + .unwrap_or_else(|| format!("contact:{}", peer.contact_id)); + best.entry(key) + .and_modify(|kept| { + // Newest advertisement wins. last_heard is RFC3339; parse rather + // than string-compare so a differing offset can't misorder. + let kept_at = chrono::DateTime::parse_from_rfc3339(&kept.last_heard).ok(); + let this_at = chrono::DateTime::parse_from_rfc3339(&peer.last_heard).ok(); + if this_at >= kept_at { + *kept = peer; + } + }) + .or_insert(peer); + } + + let mut out: Vec<&crate::mesh::types::MeshPeer> = best.into_values().collect(); + // Sort by display name, then contact_id as the tiebreak, so two peers + // sharing a name still have a total order and the list is stable across + // reads (a HashMap's iteration order is not). + out.sort_by(|a, b| { + a.advert_name + .to_lowercase() + .cmp(&b.advert_name.to_lowercase()) + .then(a.contact_id.cmp(&b.contact_id)) + }); + + out.into_iter() + .map(|p| { + serde_json::json!({ + "contact_id": p.contact_id, + "name": p.advert_name, + "lightning_uri": p.lightning_uri, + "last_heard": p.last_heard, + "reachable": p.reachable, + "hops": p.hops, + }) + }) + .collect() +} + +#[cfg(test)] +mod lightning_peer_tests { + use super::*; + use crate::mesh::types::MeshPeer; + + const URI_A: &str = + "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90@1.2.3.4:9735"; + const URI_B: &str = + "02ffeeddccbbaa998877665544332211ffeeddccbbaa998877665544332211ffee@5.6.7.8:9735"; + + fn peer(contact_id: u32, name: &str, arch: Option<&str>) -> MeshPeer { + MeshPeer { + contact_id, + advert_name: name.into(), + did: None, + pubkey_hex: Some(format!("routing{contact_id}")), + arch_pubkey_hex: arch.map(|s| s.into()), + x25519_pubkey: None, + rssi: None, + snr: None, + last_heard: "2026-08-02T10:00:00+00:00".into(), + hops: 1, + last_advert: 0, + reachable: true, + pkc_capable: false, + lat: None, + lon: None, + lightning_uri: None, + } + } + + #[test] + fn no_advertised_peers_is_an_empty_list_not_an_error() { + assert!(build_lightning_peer_list(&[]).is_empty()); + // Peers exist, but none has advertised Lightning. + let quiet = vec![peer(1, "a", None), peer(2, "b", None)]; + assert!(build_lightning_peer_list(&quiet).is_empty()); + } + + #[test] + fn only_peers_that_advertised_are_listed() { + let mut with = peer(1, "has-lightning", None); + with.lightning_uri = Some(URI_A.into()); + let list = build_lightning_peer_list(&[with, peer(2, "no-lightning", None)]); + + assert_eq!(list.len(), 1); + assert_eq!(list[0]["name"], "has-lightning"); + assert_eq!(list[0]["lightning_uri"], URI_A); + } + + #[test] + fn a_peer_that_advertised_twice_appears_once_with_the_newer_uri() { + // Same node seen as two records (radio + federation twin) sharing an + // authenticating key — the picker must offer it once, not twice. + let mut older = peer(1, "twin", Some("ARCHKEY")); + older.lightning_uri = Some(URI_A.into()); + older.last_heard = "2026-08-02T10:00:00+00:00".into(); + + let mut newer = peer(2, "twin", Some("archkey")); // case-insensitive match + newer.lightning_uri = Some(URI_B.into()); + newer.last_heard = "2026-08-02T11:30:00+00:00".into(); + + let list = build_lightning_peer_list(&[older.clone(), newer.clone()]); + assert_eq!(list.len(), 1, "twins must collapse to one entry"); + assert_eq!(list[0]["lightning_uri"], URI_B, "the newer URI must win"); + + // Order of the input must not change the outcome. + let reversed = build_lightning_peer_list(&[newer, older]); + assert_eq!(reversed[0]["lightning_uri"], URI_B); + } + + #[test] + fn a_differing_offset_cannot_misorder_the_newest_advertisement() { + // 09:30-01:00 is 10:30 UTC — LATER than 10:00Z, though it string-sorts + // earlier. Parsing rather than string-comparing is what makes this pass. + let mut utc = peer(1, "twin", Some("k")); + utc.lightning_uri = Some(URI_A.into()); + utc.last_heard = "2026-08-02T10:00:00+00:00".into(); + + let mut offset = peer(2, "twin", Some("k")); + offset.lightning_uri = Some(URI_B.into()); + offset.last_heard = "2026-08-02T09:30:00-01:00".into(); + + let list = build_lightning_peer_list(&[utc, offset]); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["lightning_uri"], URI_B); + } + + #[test] + fn ordering_is_stable_and_deterministic_across_reads() { + let mut peers = Vec::new(); + for (id, name) in [(3, "Zulu"), (1, "alpha"), (2, "Mike"), (9, "alpha")] { + let mut p = peer(id, name, Some(&format!("key{id}"))); + p.lightning_uri = Some(URI_A.into()); + peers.push(p); + } + + let first = build_lightning_peer_list(&peers); + // A HashMap's iteration order is not stable, so run it repeatedly over a + // shuffled input: the output must be byte-identical every time. + for _ in 0..8 { + peers.rotate_left(1); + assert_eq!(build_lightning_peer_list(&peers), first); + } + + let names: Vec<_> = first.iter().map(|e| e["name"].as_str().unwrap()).collect(); + assert_eq!(names, vec!["alpha", "alpha", "Mike", "Zulu"]); + // Same name -> contact_id breaks the tie, ascending. + assert_eq!(first[0]["contact_id"], 1); + assert_eq!(first[1]["contact_id"], 9); + } + + #[test] + fn send_requires_an_explicit_target_and_has_no_broadcast_form() { + assert!(parse_send_lightning_target(None).is_err(), "no params"); + assert!( + parse_send_lightning_target(Some(&serde_json::json!({}))).is_err(), + "params without contact_id must be refused, not treated as broadcast" + ); + assert!( + parse_send_lightning_target(Some(&serde_json::json!({"broadcast": true}))).is_err(), + "there is no broadcast escape hatch" + ); + assert!( + parse_send_lightning_target(Some(&serde_json::json!({"contact_id": 1u64 << 40}))) + .is_err(), + "an out-of-range contact_id must error, not silently truncate to another peer" + ); + assert_eq!( + parse_send_lightning_target(Some(&serde_json::json!({"contact_id": 42}))).unwrap(), + 42 + ); + } } diff --git a/core/archipelago/src/mesh/message_types.rs b/core/archipelago/src/mesh/message_types.rs index 3a18a4a0..9e07d280 100644 --- a/core/archipelago/src/mesh/message_types.rs +++ b/core/archipelago/src/mesh/message_types.rs @@ -1088,9 +1088,9 @@ mod tests { let bad = [ "".to_string(), "no-at-sign".to_string(), - format!("{LN_PUBKEY}"), // no host at all + LN_PUBKEY.to_string(), // no host at all format!("{LN_PUBKEY}@"), // empty host - format!("@1.2.3.4:9735"), // no pubkey + "@1.2.3.4:9735".to_string(), // no pubkey format!("{}@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey too short format!("{}z@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey not hex format!("{LN_PUBKEY}@1.2.3.4:99999"), // port out of u16 range