feat(node): warm routes to direct peers at the mesh edge + app-owned DNS seam

A mesh-edge node (only direct BLE / Wi-Fi Aware / LAN peers, sparse bloom
filters) could not route to its own directly-connected neighbours: a
neighbour is never in another peer's bloom filter, so discovery was gated
off and find_next_hop had no coordinates. Traffic to such a peer failed
with "no route to destination" even with an established session.

- discovery: don't suppress a lookup on a bloom miss unless we have >2
  peers (trustworthy blooms); and when no tree peer advertises the target,
  flood the LookupRequest to every sendable peer — including the target
  itself, which answers a lookup for its own address, so the querier learns
  its coordinates and can route.
- session: when an established-session send hits no-route, trigger discovery
  (previously only session *initiation* did), so a coordless session — e.g.
  a platform-pushed peer whose Noise handshake came up before tree
  discovery — self-warms and the app's retransmit succeeds.
- node: add Node::enable_app_owned_dns() — returns the DnsIdentityTx an
  embedder (Android VpnService pump) uses to register identities it resolved
  itself, giving the same identity-cache/route-warming the built-in DNS
  responder provides.
This commit is contained in:
Arjen
2026-07-25 10:00:26 +01:00
parent 527514689f
commit e8270970ee
3 changed files with 58 additions and 5 deletions
+29 -5
View File
@@ -439,14 +439,30 @@ impl Node {
let origin_coords = self.tree_state().my_coords().clone();
let request = LookupRequest::generate(*target, origin, origin_coords, ttl, 0);
// Send only to tree peers whose bloom filter contains the target
let peer_addrs: Vec<NodeAddr> = self
// Prefer tree peers whose bloom filter contains the target.
let mut peer_addrs: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(target))
.map(|(addr, _)| *addr)
.collect();
// Edge fallback: if no tree peer advertises the target — always the case
// for a *directly connected* target, since a neighbour is never in another
// peer's bloom — flood the request to EVERY peer we can send to, not just
// tree peers. Crucially this includes the target itself when it's a direct
// (non-tree) neighbour: a node answers a lookup for its own address, so the
// querier learns its coordinates and can route to it. Without this a
// mesh-edge node can't reach its own neighbours.
if peer_addrs.is_empty() {
peer_addrs = self
.peers
.iter()
.filter(|(_, peer)| peer.can_send())
.map(|(addr, _)| *addr)
.collect();
}
let peer_count = peer_addrs.len();
debug!(
@@ -509,14 +525,22 @@ impl Node {
return;
}
// Bloom filter pre-check: if no peer's filter contains the target,
// it's not in the mesh — skip the lookup and record as failure.
// Bloom filter pre-check: normally, if no peer's filter contains the
// target we skip. But a *directly connected* peer is never in any other
// peer's bloom (you reach it directly, not through them), so a mesh-edge
// node — one whose only peers are direct links (BLE / Wi-Fi Aware / LAN),
// with sparse blooms — could never discover coordinates for its own
// neighbours and would fail to route to them. Only suppress on a bloom
// miss when we have several peers whose blooms we can actually trust;
// otherwise fall through and flood the lookup (bounded by TTL + the
// `sent == 0` guard below).
let reachable = self.peers.values().any(|peer| peer.may_reach(dest));
if !reachable {
if !reachable && self.peers.len() > 2 {
self.metrics().discovery.req_bloom_miss.inc();
self.discovery_backoff.record_failure(dest);
debug!(
target_node = %self.peer_display_name(dest),
peers = self.peers.len(),
"Discovery skipped, target not in any peer bloom filter"
);
return;
+9
View File
@@ -2140,6 +2140,15 @@ impl Node {
}
if let Err(e) = self.send_ipv6_packet(&dest_addr, &ipv6_packet).await {
debug!(dest = %self.peer_display_name(&dest_addr), error = %e, "Failed to send TUN packet via session");
// An established session can still have no route when its
// coordinates were never learned or went stale — e.g. a
// platform-pushed peer (Wi-Fi Aware / LAN) whose Noise session
// came up before tree discovery. Unlike session initiation
// (below), this path didn't trigger a lookup, so the route
// never warmed and every packet was silently dropped. Kick
// discovery (rate-limited internally); the app's retransmit
// then succeeds once coordinates arrive.
self.maybe_initiate_lookup(&dest_addr).await;
}
return;
}
+20
View File
@@ -2910,6 +2910,26 @@ impl Node {
(outbound_tx, tun_rx)
}
/// Set up an **app-owned DNS resolver**: an embedder that answers `.fips`
/// queries itself (e.g. the Android VpnService packet pump, which has no
/// system DNS socket) returns each resolved identity through the sender this
/// returns. The `run_rx_loop` consumes them and calls
/// [`Self::register_identity`] — the same identity-cache population (and hence
/// route warming) the built-in [`crate::upper::dns::run_dns_responder`] does.
///
/// Without this the embedder's answers resolve the AAAA but leave the node's
/// identity cache empty, so the first outbound packet to a freshly-resolved
/// `<npub>.fips` has no cached pubkey to open a session with and is dropped.
///
/// Call after [`Node::new`] and **before** [`Self::start`], like
/// [`Self::enable_app_owned_tun`].
pub fn enable_app_owned_dns(&mut self) -> crate::upper::dns::DnsIdentityTx {
let size = self.config().node.buffers.tun_channel.max(1);
let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(size);
self.dns_identity_rx = Some(identity_rx);
identity_tx
}
// === Sending ===
/// Encrypt and send a link-layer message to an authenticated peer.