feat(01-04): expose meshed Lightning peers and the send path over RPC (FED-05)

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 22:48:02 -04:00
co-authored by Claude Opus 5
parent decb7c713b
commit 666990c684
4 changed files with 433 additions and 2 deletions
@@ -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.
@@ -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,
@@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<u32> {
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<serde_json::Value> {
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<String, &crate::mesh::types::MeshPeer> = 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
);
}
}
+2 -2
View File
@@ -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