Author SHA1 Message Date
Arjen 9121925d4b feat(ble/android): adopt a radio without rebuilding the node
`AndroidIo` captured the injected bridge `Arc` at construction, and the BLE
transport was only built if a bridge already existed when the node started.
Both facts are wrong for the platform this backend serves: the radio is owned
by an Android foreground service whose lifetime is independent of the node's —
it can start *after* the node, and it mints a fresh bridge every time it
starts. So the only way for a running node to adopt a radio was to stop and
rebuild it, which drops every peer, every session, and every route with it.
Turning Bluetooth on took the whole mesh down for as long as re-handshaking
took, which is not what enabling a transport should cost.

`AndroidIo` now resolves the process-wide bridge per operation, so a fresh
bridge is picked up in place, and the transport is constructed whether or not
one exists yet. Operations attempted with no radio present return a transport
error instead of being unreachable, and recover on their own once a radio
appears. `AndroidIo::new` is kept for tests that drive a specific bridge.

Live streams still hold the radio they were opened on rather than migrating to
a new one — a channel belongs to the socket that created it, and those die with
the radio that owned them.
2026-07-29 16:12:35 +01:00
Arjen 006c3314ad feat(node): expose the UDP transport fd for app-owned socket binding
The UDP transport opens one wildcard socket and selects the egress path per
destination address. That assumes the host routes by destination alone —
true on an ordinary Unix box, not true everywhere. On hosts that associate
each socket with one "network" and steer traffic by that association, a peer
reachable only over a secondary network is unreachable in a way FIPS cannot
see or fix: the address is well-formed, the send succeeds, the peer receives
our handshake and replies, and the reply is discarded by the host before it
reaches our socket. The link just retries msg1 forever.

Nothing in FIPS can correct that, because the correction is a socket option
on a socket the transport keeps private. So hand the embedder the fd:

  let rx = node.enable_app_owned_udp_fd();   // before start()
  node.start().await?;
  let fd = rx.recv()?;                       // once the transport is up

This follows the existing `enable_app_owned_tun` / `enable_app_owned_dns`
contract — call after `Node::new` and before `start()`, get a channel back —
and stays deliberately narrow: FIPS keeps owning the socket, and the fd
carries no promise beyond "this is the transport's socket, now open". If no
UDP transport is configured, or it fails to start, nothing is ever sent.

Also plumbs `raw_fd()` through `TransportHandle` and `UdpTransport`. Unix
only, since `RawFd` is a unix concept; non-UDP transports return None.
2026-07-26 17:56:59 +01:00
Arjen e8270970ee 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.
2026-07-25 10:00:26 +01:00
Arjen 527514689f feat(peer): transport-preference roaming cutover
When a peer is reachable over more than one transport at once, prefer the
faster one and hold it. roam_current_addr upgrades eagerly to a higher-
preference transport (BLE -> Wi-Fi Aware / UDP) but falls back to a lower
one only after the preferred link has been silent past a hysteresis
window (~2 heartbeat intervals), so a stray BLE keepalive can't drag an
active Aware session back to BLE. poll_transport_discovery additionally
stops re-probing an alternate path strictly worse than the one the peer
is already on, so BLE rediscovery no longer keeps grabbing a peer that
belongs on Aware. Equal preferences reduce to plain last-authenticated-
packet-wins roaming, so single-transport deployments are unaffected.
2026-07-14 13:54:05 +02:00
Arjen 0aed417dbd feat(discovery): platform-pushed peer queue
Add a transport-agnostic seam for an embedding platform (e.g. an Android
Wi-Fi Aware radio) to push "peer npub reachable at addr over transport T"
events into a running node — the generalization of the UDP-only LAN mDNS
drain. A process-global queue (fips::discovery::platform) is drained each
tick by poll_platform_discovery, which selects the transport family-aware
(an IPv6 target picks an IPv6 socket) and initiates a Noise IK handshake;
the pushed npub is only a routing hint, the handshake authenticates. An
event for an already-active peer starts an alternate-path handshake, and
a Lost event closes the pooled connection.
2026-07-14 13:54:05 +02:00
Arjen 3ae4ac725b fix(transport/udp): preserve sin6_scope_id on receive
sockaddr_to_socket_addr dropped the IPv6 scope id when converting an
inbound source address, so a reply to a link-local peer (fe80::/10) had
no interface scope and could not be routed. This stalled the Noise
handshake reply (msg2) over a Wi-Fi Aware NDP interface, where every
address is link-local: msg1 reached the peer (sent to a scoped address)
but msg2 never routed back. Rebuild the SocketAddrV6 with sin6_scope_id;
non-link-local sources carry scope 0 and are unaffected.
2026-07-14 13:54:05 +02:00
13 changed files with 698 additions and 82 deletions
+1
View File
@@ -8,6 +8,7 @@
pub mod lan;
pub mod nostr;
pub mod platform;
use crate::config::UdpConfig;
use crate::{NodeAddr, TransportId};
+141
View File
@@ -0,0 +1,141 @@
//! Platform-pushed peer discovery.
//!
//! A generic seam for an embedding platform (e.g. an Android app layer that
//! runs its own radio discovery, such as Wi-Fi Aware) to push "peer `npub` is
//! reachable at `addr` over transport type `T`" events into a running node —
//! the transport-agnostic generalization of the LAN mDNS drain
//! (`poll_lan_discovery`), which delivers the same shape but is hardwired to
//! UDP transports.
//!
//! The queue is a process-global, like the Android BLE bridge injection seam
//! (`set_android_ble_bridge`): the embedder pushes without holding a `Node`
//! handle, and the node drains once per tick in `poll_platform_discovery`.
//! Events pushed while no node is running are retained up to [`QUEUE_CAP`]
//! (oldest dropped first) so a push racing a node rebuild is not lost.
//! With more than one node in a process, whichever drains first consumes
//! the events (same caveat as the BLE bridge) — intended for the
//! single-node embedding case.
//!
//! The pushed npub is only a routing hint: the Noise IK handshake is the
//! authentication, exactly as with mDNS adverts — a spoofed push fails the
//! IK exchange and is dropped.
use std::collections::VecDeque;
use std::sync::Mutex;
/// Maximum retained events while undrained. Beyond this the oldest event is
/// dropped: platform pushes are periodic (radio discovery re-fires), so a
/// dropped event is re-learned, while an unbounded queue would grow forever
/// if the node is stopped.
const QUEUE_CAP: usize = 256;
/// A peer reachability event pushed by the embedding platform.
///
/// Addresses and identities are strings at this seam (it is crossed from
/// JNI); they are parsed and validated at drain time, where a bad value is
/// logged and skipped rather than surfaced to the pusher.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlatformPeerEvent {
/// The platform established reachability: dial `addr` on an operational
/// transport whose type name matches `transport_type`. For `udp` the
/// selection is family-aware — an IPv6 target picks an IPv6-capable
/// socket, never a wildcard IPv4 one. For IPv6 link-local addresses the
/// scope must be a numeric ifindex (`"[fe80::x%3]:4870"`) —
/// interface-name scopes do not parse.
Available {
npub: String,
addr: String,
transport_type: String,
},
/// The platform observed the link go away (e.g. the Wi-Fi Aware data
/// path was lost). The node closes any pooled connection it holds for
/// the peer's current address on that transport so a dead socket is
/// not re-used; reconnection is left to the ordinary machinery.
Lost {
npub: String,
transport_type: String,
},
}
static QUEUE: Mutex<VecDeque<PlatformPeerEvent>> = Mutex::new(VecDeque::new());
fn push(event: PlatformPeerEvent) {
let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner());
if queue.len() >= QUEUE_CAP {
queue.pop_front();
}
queue.push_back(event);
}
/// Push "peer is reachable at `addr` over `transport_type`".
pub fn platform_peer_available(npub: &str, addr: &str, transport_type: &str) {
push(PlatformPeerEvent::Available {
npub: npub.to_string(),
addr: addr.to_string(),
transport_type: transport_type.to_string(),
});
}
/// Push "the platform-managed link to peer went away".
pub fn platform_peer_lost(npub: &str, transport_type: &str) {
push(PlatformPeerEvent::Lost {
npub: npub.to_string(),
transport_type: transport_type.to_string(),
});
}
/// Drain all queued events. Called by the node once per tick.
pub fn drain_platform_peer_events() -> Vec<PlatformPeerEvent> {
let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner());
queue.drain(..).collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// The queue is a process-global, so tests touching it must not
/// interleave across test threads.
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn push_drain_roundtrip() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
drain_platform_peer_events();
platform_peer_available("npub1abc", "[fe80::1%3]:4870", "tcp");
platform_peer_lost("npub1abc", "tcp");
let events = drain_platform_peer_events();
assert_eq!(events.len(), 2);
assert_eq!(
events[0],
PlatformPeerEvent::Available {
npub: "npub1abc".into(),
addr: "[fe80::1%3]:4870".into(),
transport_type: "tcp".into(),
}
);
assert_eq!(
events[1],
PlatformPeerEvent::Lost {
npub: "npub1abc".into(),
transport_type: "tcp".into(),
}
);
assert!(drain_platform_peer_events().is_empty());
}
#[test]
fn queue_caps_by_dropping_oldest() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
drain_platform_peer_events();
for i in 0..(QUEUE_CAP + 10) {
platform_peer_available(&format!("npub{i}"), "addr", "tcp");
}
let events = drain_platform_peer_events();
assert_eq!(events.len(), QUEUE_CAP);
match &events[0] {
PlatformPeerEvent::Available { npub, .. } => assert_eq!(npub, "npub10"),
other => panic!("unexpected event: {other:?}"),
}
}
}
+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;
+21 -2
View File
@@ -263,6 +263,11 @@ impl Node {
let ce_flag = header.flags & FLAG_CE != 0;
let sp_flag = header.flags & FLAG_SP != 0;
// Transport-preference cutover: gate roaming so a faster transport
// (Wi-Fi Aware / UDP) isn't dragged back to a slower one (BLE) by a
// stray packet. Computed before the peer borrow (needs `self`).
let roam_pref = self.transport_preference(packet.transport_id);
let roam_hysteresis = self.roam_hysteresis_ms();
if let Some(peer) = self.peers.get_mut(&node_addr) {
if let Some(mmp) = peer.mmp_mut() {
mmp.receiver.record_recv(
@@ -274,7 +279,13 @@ impl Node {
);
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now);
}
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
peer.roam_current_addr(
packet.transport_id,
packet.remote_addr.clone(),
roam_pref,
packet.timestamp_ms,
roam_hysteresis,
);
peer.link_stats_mut()
.record_recv(packet.data.len(), packet.timestamp_ms);
peer.touch(packet.timestamp_ms);
@@ -356,10 +367,18 @@ impl Node {
return;
};
let now = Instant::now();
let roam_pref = self.transport_preference(transport_id);
let roam_hysteresis = self.roam_hysteresis_ms();
let mut address_changed = false;
if let Some(peer) = self.peers.get_mut(node_addr) {
peer.reset_decrypt_failures();
address_changed = peer.set_current_addr(transport_id, remote_addr.clone());
address_changed = peer.roam_current_addr(
transport_id,
remote_addr.clone(),
roam_pref,
packet_timestamp_ms,
roam_hysteresis,
);
peer.link_stats_mut()
.record_recv(packet_len, packet_timestamp_ms);
peer.touch(packet_timestamp_ms);
+1
View File
@@ -264,6 +264,7 @@ impl Node {
self.poll_pending_connects().await;
self.poll_nostr_discovery().await;
self.poll_lan_discovery().await;
self.poll_platform_discovery().await;
self.resend_pending_handshakes(now_ms).await;
self.resend_pending_rekeys(now_ms).await;
self.resend_pending_session_handshakes(now_ms).await;
+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;
}
+182 -27
View File
@@ -367,6 +367,27 @@ impl Node {
.min_by_key(|(id, _)| id.as_u32())
}
/// Resolve a discovered `(transport_type, addr)` pair to an operational
/// transport. For `udp` addresses the socket family matters — a wildcard
/// IPv4 socket cannot send to an IPv6 link-local target (e.g. a Wi-Fi
/// Aware data path) — so when the address parses as a socket address the
/// selection is family-aware and skips bootstrap-adopted sockets. All
/// other transport types match by name alone.
fn find_transport_for_discovered_addr(
&self,
transport_type: &str,
addr: &str,
) -> Option<TransportId> {
if transport_type == "udp"
&& let Ok(remote_addr) = addr.parse::<SocketAddr>()
{
return self
.find_udp_transport_for_remote_addr(remote_addr)
.map(|(id, _)| id);
}
self.find_transport_for_type(transport_type)
}
/// Initiate a connection to a peer on a specific transport and address.
///
/// For connectionless transports (UDP, Ethernet): allocates a link, starts
@@ -599,6 +620,18 @@ impl Node {
let remote_addr = peer.addr;
if self.peers.contains_key(&node_addr) {
// Don't re-probe an alternate path that is strictly worse
// than the one the peer is already on: a peer settled on a
// faster transport (Wi-Fi Aware / UDP) must not be pulled
// back to BLE by BLE rediscovery. Equal-or-better candidates
// still refresh, so BLE → Aware upgrades proceed.
if let Some(cur) = self.peers.get(&node_addr).and_then(|p| p.transport_id()) {
if self.transport_preference(cur)
> self.transport_preference(candidate_transport_id)
{
continue;
}
}
let transport_name = transport.transport_type().name;
let candidate = PeerAddress::new(transport_name, remote_addr.to_string());
if self.active_peer_candidate_is_fresh_enough_to_skip(
@@ -956,6 +989,139 @@ impl Node {
}
}
/// Drain platform-pushed peers and initiate Noise IK handshakes.
///
/// The transport-agnostic sibling of `poll_lan_discovery`: an embedding
/// platform (e.g. the Android Wi-Fi Aware radio) pushes
/// `(npub, addr, transport type)` events into the process-global queue
/// (`crate::discovery::platform`) and this drains them once per tick.
/// As with mDNS, the pushed npub is only a hint — the IK handshake is
/// the authentication.
///
/// Unlike the LAN drain, an event for an already-active peer starts an
/// alternate-path handshake (gated by the same freshness/in-flight
/// checks as `poll_transport_discovery`), so a platform push can move a
/// peer onto a faster transport — the BLE→Wi-Fi-Aware cutover.
pub(super) async fn poll_platform_discovery(&mut self) {
let events = crate::discovery::platform::drain_platform_peer_events();
if events.is_empty() {
return;
}
let mut connect_budget = self.discovery_connect_budget();
for event in events {
match event {
crate::discovery::platform::PlatformPeerEvent::Available {
npub,
addr,
transport_type,
} => {
let Some(transport_id) =
self.find_transport_for_discovered_addr(&transport_type, &addr)
else {
debug!(
npub = %npub,
transport_type = %transport_type,
addr = %addr,
"platform: skip pushed peer with no compatible operational transport"
);
continue;
};
let identity = match crate::PeerIdentity::from_npub(&npub) {
Ok(id) => id,
Err(err) => {
debug!(npub = %npub, error = %err, "platform: skip bad npub");
continue;
}
};
let peer_node_addr = *identity.node_addr();
if peer_node_addr == *self.identity().node_addr() {
continue;
}
let remote_addr = crate::transport::TransportAddr::from_string(&addr);
if self.peers.contains_key(&peer_node_addr) {
// Active peer: this is a path upgrade, not a first
// contact — apply the alternate-path gates.
let candidate = PeerAddress::new(&transport_type, addr.clone());
if self.active_peer_candidate_is_fresh_enough_to_skip(
&peer_node_addr,
std::slice::from_ref(&candidate),
) {
continue;
}
}
if self.is_connecting_to_peer_on_path(
&peer_node_addr,
transport_id,
&remote_addr,
) {
continue;
}
if connect_budget == 0 {
debug!(npub = %npub, "platform: connect budget exhausted");
continue;
}
connect_budget = connect_budget.saturating_sub(1);
info!(
peer = %self.peer_display_name(&peer_node_addr),
transport_id = %transport_id,
remote_addr = %remote_addr,
"platform: initiating handshake to pushed peer"
);
if let Err(err) = self
.initiate_connection(transport_id, remote_addr, identity)
.await
{
debug!(
npub = %npub,
error = %err,
"platform: failed to initiate connection to pushed peer"
);
}
}
crate::discovery::platform::PlatformPeerEvent::Lost {
npub,
transport_type,
} => {
let Ok(identity) = crate::PeerIdentity::from_npub(&npub) else {
continue;
};
let peer_node_addr = *identity.node_addr();
let Some(peer) = self.peers.get(&peer_node_addr) else {
continue;
};
// Only act if the peer currently sits on the named
// transport type: close the pooled connection so the
// dead socket is not re-used. Reconnection (including
// falling back to another transport) is the ordinary
// machinery's job.
let (Some(transport_id), Some(current_addr)) =
(peer.transport_id(), peer.current_addr().cloned())
else {
continue;
};
let on_named_transport = self
.transports
.get(&transport_id)
.map(|t| t.transport_type().name == transport_type)
.unwrap_or(false);
if !on_named_transport {
continue;
}
info!(
peer = %self.peer_display_name(&peer_node_addr),
transport_id = %transport_id,
remote_addr = %current_addr,
"platform: closing connection for lost pushed peer"
);
if let Some(transport) = self.transports.get(&transport_id) {
transport.close_connection(&current_addr).await;
}
}
}
}
}
/// Poll pending transport connects and initiate handshakes for ready ones.
///
/// Called from the tick handler. For each pending connect, queries the
@@ -1076,6 +1242,12 @@ impl Node {
match handle.start().await {
Ok(()) => {
#[cfg(unix)]
if transport_type == "udp"
&& let (Some(tx), Some(fd)) = (&self.udp_bind_tx, handle.raw_fd())
{
let _ = tx.send(fd);
}
self.transports.insert(transport_id, handle);
}
Err(e) => {
@@ -1769,34 +1941,17 @@ impl Node {
continue;
}
} else {
let tid = if addr.transport == "udp"
&& let Ok(remote_socket_addr) = addr.addr.parse::<SocketAddr>()
{
match self.find_udp_transport_for_remote_addr(remote_socket_addr) {
Some((id, _)) => id,
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No compatible operational UDP transport for address"
);
continue;
}
match self.find_transport_for_discovered_addr(&addr.transport, &addr.addr) {
Some(tid) => (tid, TransportAddr::from_string(&addr.addr)),
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No compatible operational transport for address"
);
continue;
}
} else {
match self.find_transport_for_type(&addr.transport) {
Some(id) => id,
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No operational transport for address type"
);
continue;
}
}
};
(tid, TransportAddr::from_string(&addr.addr))
}
};
if self.is_connecting_to_peer_on_path(&peer_node_addr, transport_id, &remote_addr) {
+109 -23
View File
@@ -440,6 +440,13 @@ pub struct Node {
/// DNS responder task handle.
dns_task: Option<tokio::task::JoinHandle<()>>,
// === App-owned UDP socket binding ===
/// Fires once with the UDP transport's raw fd right after it starts, so an
/// embedder can apply host-specific socket options (e.g. pinning it to one
/// of several OS networks). See [`Self::enable_app_owned_udp_fd`].
#[cfg(unix)]
udp_bind_tx: Option<std::sync::mpsc::Sender<std::os::unix::io::RawFd>>,
// === Index-Based Session Dispatch ===
/// Allocator for session indices.
index_allocator: IndexAllocator,
@@ -699,6 +706,8 @@ impl Node {
tun_shutdown_fd: None,
dns_identity_rx: None,
dns_task: None,
#[cfg(unix)]
udp_bind_tx: None,
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
@@ -860,6 +869,8 @@ impl Node {
tun_shutdown_fd: None,
dns_identity_rx: None,
dns_task: None,
#[cfg(unix)]
udp_bind_tx: None,
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
@@ -1054,29 +1065,33 @@ impl Node {
.iter()
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.collect();
match crate::transport::ble::android_io::android_ble_bridge() {
Some(bridge) => {
for (name, ble_config) in ble_instances {
let transport_id = self.allocate_transport_id();
let io = crate::transport::ble::android_io::AndroidIo::new(
std::sync::Arc::clone(&bridge),
);
let mut ble = crate::transport::ble::BleTransport::new(
transport_id,
name,
ble_config,
io,
packet_tx.clone(),
);
ble.set_local_pubkey(self.identity().pubkey().serialize());
transports.push(TransportHandle::Ble(ble));
}
}
None => {
if !ble_instances.is_empty() {
tracing::warn!("BLE configured but no Android radio bridge injected");
}
}
// Built whether or not a radio bridge exists yet, and without
// capturing the one that does: `AndroidIo` resolves the process-wide
// bridge per operation. The radio is owned by an Android foreground
// service whose lifetime is independent of the node's — it can start
// after the node, and it mints a fresh bridge every time it starts.
// Binding either fact into the transport at construction meant the
// only way to pick up a radio was to stop and rebuild the node,
// which drops every peer and session. Operations attempted while no
// radio is present fail as transport errors and recover on their own
// once one appears.
if !ble_instances.is_empty()
&& crate::transport::ble::android_io::android_ble_bridge().is_none()
{
tracing::info!("BLE configured; waiting for the Android radio bridge");
}
for (name, ble_config) in ble_instances {
let transport_id = self.allocate_transport_id();
let io = crate::transport::ble::android_io::AndroidIo::from_global();
let mut ble = crate::transport::ble::BleTransport::new(
transport_id,
name,
ble_config,
io,
packet_tx.clone(),
);
ble.set_local_pubkey(self.identity().pubkey().serialize());
transports.push(TransportHandle::Ble(ble));
}
}
@@ -1093,6 +1108,29 @@ impl Node {
.map(|(id, _)| *id)
}
/// Link preference of a transport instance by id (higher = preferred),
/// from its transport type. See [`crate::transport::transport_link_preference`].
/// Unknown ids get 0 so any real transport outranks a stale/removed one.
pub(crate) fn transport_preference(&self, id: TransportId) -> u8 {
self.transports
.get(&id)
.map(|h| crate::transport::transport_link_preference(h.transport_type().name))
.unwrap_or(0)
}
/// How long a preferred transport must be silent before a lower-preference
/// transport may take over the peer's link (the roaming cutover hysteresis).
/// ~2 heartbeat intervals so a single missed heartbeat can't cause a
/// spurious downgrade; floored so a tiny configured interval can't flap.
pub(crate) fn roam_hysteresis_ms(&self) -> u64 {
(self
.config()
.node
.heartbeat_interval_secs
.saturating_mul(2_000))
.max(15_000)
}
/// Resolve an Ethernet peer address ("interface/mac") to a transport ID
/// and binary TransportAddr.
///
@@ -2887,6 +2925,54 @@ 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
}
/// Set up an **app-owned UDP socket binding**: the returned receiver gets
/// the UDP transport's raw socket fd once, right after the transport opens
/// inside [`Self::start`], so an embedder can apply a socket option FIPS
/// itself has no way to choose — in particular pinning the socket to one
/// of several networks the host OS offers.
///
/// FIPS opens a single wildcard UDP socket and picks the egress path per
/// destination address, which assumes the OS routes by destination alone.
/// Some hosts don't: where the OS binds each socket to one "network" and
/// steers replies by that association, a peer reachable only over a
/// secondary network (a link-local address on an interface that is not
/// the default route) can receive our handshake and have its reply
/// discarded before it reaches the socket. The fd is the only handle that
/// lets the embedder correct this, and it is otherwise private to the
/// transport.
///
/// Call after [`Node::new`] and before [`Self::start`], like
/// [`Self::enable_app_owned_tun`]. Nothing is ever sent if no UDP
/// transport is configured or it fails to start.
#[cfg(unix)]
pub fn enable_app_owned_udp_fd(
&mut self,
) -> std::sync::mpsc::Receiver<std::os::unix::io::RawFd> {
let (tx, rx) = std::sync::mpsc::channel();
self.udp_bind_tx = Some(tx);
rx
}
// === Sending ===
/// Encrypt and send a link-layer message to an authenticated peer.
+88
View File
@@ -99,6 +99,14 @@ pub struct ActivePeer {
transport_id: Option<TransportId>,
/// Current transport address (for roaming support).
current_addr: Option<TransportAddr>,
/// Link preference of the current transport (higher = preferred). Gates
/// roaming so a fast transport (e.g. Wi-Fi Aware / UDP) is not dragged back
/// onto a slow one (BLE) by a stray packet — see `roam_current_addr`.
current_transport_preference: u8,
/// When the current transport last delivered an authenticated packet (Unix
/// ms). Lets the roam gate detect that the preferred transport has gone
/// silent, so a lower-preference transport may take over.
current_transport_last_recv_ms: u64,
// === Spanning Tree ===
/// Their latest parent declaration.
@@ -232,6 +240,8 @@ impl ActivePeer {
their_index: None,
transport_id: None,
current_addr: None,
current_transport_preference: 0,
current_transport_last_recv_ms: 0,
declaration: None,
ancestry: None,
tree_announce_min_interval_ms: 500,
@@ -318,6 +328,11 @@ impl ActivePeer {
their_index: Some(their_index),
transport_id: Some(transport_id),
current_addr: Some(current_addr),
// Preference 0 until the first authenticated data packet sets the
// real value (via roam_current_addr); last-recv starts fresh at
// authentication so the preferred link isn't seen as stale.
current_transport_preference: 0,
current_transport_last_recv_ms: authenticated_at,
declaration: None,
ancestry: None,
tree_announce_min_interval_ms: 500,
@@ -535,6 +550,45 @@ impl ActivePeer {
changed
}
/// Roam to `(transport_id, addr)` under the transport-preference cutover
/// policy, returning whether `(transport_id, addr)` actually changed.
///
/// The new path is adopted when it is the current transport (ordinary
/// address roaming, e.g. BLE MAC rotation), when its `preference` is `>=`
/// the current transport's (eager upgrade to a faster link — BLE →
/// Wi-Fi Aware), or when the current, higher-preference transport has gone
/// silent for at least `hysteresis_ms` (the preferred link died — fall
/// back). A lower-preference packet arriving on a still-live preferred
/// transport is ignored for roaming, so e.g. a BLE keepalive cannot drag an
/// active Wi-Fi Aware session back onto BLE. Equal preferences reduce to
/// plain last-authenticated-packet-wins roaming.
pub fn roam_current_addr(
&mut self,
transport_id: TransportId,
addr: TransportAddr,
preference: u8,
now_ms: u64,
hysteresis_ms: u64,
) -> bool {
let same_transport = self.transport_id == Some(transport_id);
let adopt = self.transport_id.is_none()
|| same_transport
|| preference >= self.current_transport_preference
|| now_ms.saturating_sub(self.current_transport_last_recv_ms) >= hysteresis_ms;
if !adopt {
// Stay on the preferred transport; do not refresh its last-recv,
// so it keeps aging toward the hysteresis fallback if it is dead.
return false;
}
let changed =
self.transport_id != Some(transport_id) || self.current_addr.as_ref() != Some(&addr);
self.transport_id = Some(transport_id);
self.current_addr = Some(addr);
self.current_transport_preference = preference;
self.current_transport_last_recv_ms = now_ms;
changed
}
// === Handshake Resend ===
/// Store wire-format msg2 for resend on duplicate msg1.
@@ -1245,6 +1299,40 @@ mod tests {
assert!(!peer.can_send());
}
#[test]
fn test_roam_transport_preference_cutover() {
let mut peer = ActivePeer::new(make_peer_identity(), LinkId::new(1), 1000);
let ble = TransportId::new(1);
let udp = TransportId::new(2);
let ble_addr = TransportAddr::from_string("ble0/AA:BB");
let udp_addr = TransportAddr::from_string("[fe80::1%3]:4870");
const BLE_PREF: u8 = 50;
const UDP_PREF: u8 = 100;
const HYST: u64 = 20_000;
// First packet (no current transport): adopted.
assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1000, HYST));
assert_eq!(peer.transport_id(), Some(ble));
// Higher-preference transport: eager upgrade BLE -> Aware/UDP.
assert!(peer.roam_current_addr(udp, udp_addr.clone(), UDP_PREF, 1100, HYST));
assert_eq!(peer.transport_id(), Some(udp));
// Lower-preference keepalive while UDP is fresh: ignored, stays on UDP.
assert!(!peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1200, HYST));
assert_eq!(peer.transport_id(), Some(udp));
// UDP silent past the hysteresis window: a BLE packet now wins (fall
// back). Last UDP recv was 1100; this arrives at 1100 + HYST + 1.
assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1100 + HYST + 1, HYST));
assert_eq!(peer.transport_id(), Some(ble));
// Same-transport address roaming is always allowed.
let ble_addr2 = TransportAddr::from_string("ble0/CC:DD");
assert!(peer.roam_current_addr(ble, ble_addr2.clone(), BLE_PREF, 1100 + HYST + 2, HYST));
assert_eq!(peer.current_addr(), Some(&ble_addr2));
}
#[test]
fn test_tree_position() {
let identity = make_peer_identity();
+56 -24
View File
@@ -387,12 +387,44 @@ pub fn android_ble_bridge() -> Option<Arc<AndroidBleBridge>> {
/// macOS/Android-style external-radio backend over [`AndroidBleBridge`].
pub struct AndroidIo {
bridge: Arc<AndroidBleBridge>,
/// Bridge to use when one was supplied explicitly (tests). In the app this
/// is `None` and every operation resolves [`android_ble_bridge`] instead.
///
/// Resolving per call is what lets the radio be replaced without rebuilding
/// the node. The Android service mints a fresh bridge each time it starts
/// (a new `BleRadio`, a new JNI handle); if this type captured that `Arc` at
/// construction, a running node would keep driving the dead one, and the
/// only way to adopt the new radio would be to stop and restart the node —
/// dropping every peer and every session with it.
bridge: Option<Arc<AndroidBleBridge>>,
}
impl AndroidIo {
/// Resolve the bridge from the process-wide slot on each operation.
pub fn from_global() -> Self {
Self { bridge: None }
}
/// Pin a specific bridge, for tests that drive one directly.
pub fn new(bridge: Arc<AndroidBleBridge>) -> Self {
Self { bridge }
Self {
bridge: Some(bridge),
}
}
/// The bridge for this operation. `None` once the radio has gone away —
/// callers surface that as a transport error rather than panicking, since
/// it is reachable simply by toggling Bluetooth off mid-operation.
fn bridge(&self) -> Option<Arc<AndroidBleBridge>> {
match &self.bridge {
Some(b) => Some(Arc::clone(b)),
None => android_ble_bridge(),
}
}
fn require_bridge(&self) -> Result<Arc<AndroidBleBridge>, TransportError> {
self.bridge()
.ok_or_else(|| TransportError::Io(std::io::Error::other("BLE radio not available")))
}
}
@@ -533,42 +565,40 @@ impl BleIo for AndroidIo {
type Scanner = AndroidScanner;
async fn listen(&self, _psm: u16) -> Result<AndroidAcceptor, TransportError> {
let bridge = self.require_bridge()?;
// Android assigns the listener PSM; the `psm` arg from FIPS is ignored.
let os_psm = self.bridge.radio.listen();
let os_psm = bridge.radio.listen();
if os_psm != 0 {
self.bridge.local_psm.store(os_psm, Ordering::Relaxed);
bridge.local_psm.store(os_psm, Ordering::Relaxed);
}
let rx = self
.bridge
let rx = bridge
.accept_rx
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
Ok(AndroidAcceptor {
rx,
radio: Arc::clone(&self.bridge.radio),
radio: Arc::clone(&bridge.radio),
})
}
async fn connect(&self, addr: &BleAddr, psm: u16) -> Result<AndroidStream, TransportError> {
let bridge = self.require_bridge()?;
// Substitute the learned per-peer PSM for this address, if known.
let dial_psm = self.bridge.psm_map.resolve(addr, psm);
let connect_id = self.bridge.next_id.fetch_add(1, Ordering::Relaxed);
let dial_psm = bridge.psm_map.resolve(addr, psm);
let connect_id = bridge.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.bridge
bridge
.connects
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(connect_id, tx);
self.bridge.radio.connect(connect_id, addr, dial_psm);
bridge.radio.connect(connect_id, addr, dial_psm);
// FIPS already wraps connect() in a timeout, so we just await the result.
match rx.await {
Ok(ep) => Ok(AndroidStream::from_endpoints(
ep,
Arc::clone(&self.bridge.radio),
)),
Ok(ep) => Ok(AndroidStream::from_endpoints(ep, Arc::clone(&bridge.radio))),
Err(_) => {
self.bridge
bridge
.connects
.lock()
.unwrap_or_else(|e| e.into_inner())
@@ -581,24 +611,26 @@ impl BleIo for AndroidIo {
}
async fn start_advertising(&self) -> Result<(), TransportError> {
self.bridge
let bridge = self.require_bridge()?;
bridge
.radio
.start_advertising(self.bridge.local_psm.load(Ordering::Relaxed));
.start_advertising(bridge.local_psm.load(Ordering::Relaxed));
Ok(())
}
async fn stop_advertising(&self) -> Result<(), TransportError> {
self.bridge.radio.stop_advertising();
let bridge = self.require_bridge()?;
bridge.radio.stop_advertising();
Ok(())
}
async fn start_scanning(&self) -> Result<AndroidScanner, TransportError> {
let bridge = self.require_bridge()?;
// Re-learn PSMs / adverts each scan cycle (addresses rotate with MAC).
self.bridge.psm_map.clear();
self.bridge.clear_adverts();
self.bridge.radio.start_scanning();
let rx = self
.bridge
bridge.psm_map.clear();
bridge.clear_adverts();
bridge.radio.start_scanning();
let rx = bridge
.scan_rx
.lock()
.unwrap_or_else(|e| e.into_inner())
+39
View File
@@ -280,6 +280,27 @@ impl fmt::Display for TransportType {
}
}
/// Link preference for a transport type — higher is preferred for carrying a
/// peer's traffic when that peer is reachable over more than one transport at
/// once. Used by the roaming cutover (`ActivePeer::roam_current_addr`) so a
/// peer on a fast transport (Wi-Fi Aware / UDP) is not dragged onto a slow
/// one (BLE) by a stray packet. Equal preferences reduce to plain
/// last-authenticated-packet-wins roaming, so single-transport deployments are
/// unaffected.
pub fn transport_link_preference(name: &str) -> u8 {
match name {
// High-bandwidth IP transports (incl. the Wi-Fi Aware data path, which
// rides the UDP transport).
"wifi" | "udp" | "tcp" => 100,
"ethernet" => 90,
// Always-on but low-bandwidth control link.
"ble" => 50,
// Anonymity overlays: high latency.
"tor" | "nym" => 40,
_ => 80,
}
}
// ============================================================================
// Transport State
// ============================================================================
@@ -1074,6 +1095,24 @@ impl TransportHandle {
}
}
/// Raw fd of the bound socket (UDP only, returns None for other
/// transports). See [`crate::transport::udp::UdpTransport::raw_fd`].
#[cfg(unix)]
pub fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> {
match self {
TransportHandle::Udp(t) => t.raw_fd(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
TransportHandle::Ethernet(_) => None,
TransportHandle::Tcp(_) => None,
TransportHandle::Tor(_) => None,
TransportHandle::Nym(_) => None,
#[cfg(ble_available)]
TransportHandle::Ble(_) => None,
#[cfg(test)]
TransportHandle::Loopback(_) => None,
}
}
/// Get the interface name (Ethernet only, returns None for other transports).
pub fn interface_name(&self) -> Option<&str> {
match self {
+11
View File
@@ -89,6 +89,17 @@ impl UdpTransport {
self.local_addr
}
/// Raw fd of the bound socket, so an embedder can apply socket options
/// this transport does not manage itself — notably binding it to one of
/// several networks a host OS offers, when destination-based routing alone
/// does not reach a peer (see [`crate::Node::enable_app_owned_udp_fd`]).
/// `None` before start.
#[cfg(unix)]
pub fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> {
use std::os::unix::io::AsRawFd;
self.socket.as_ref().map(|s| s.as_raw_fd())
}
/// Configured recv buffer size — used when opening per-peer
/// `ConnectedPeerSocket`s so they get the same buffer ceiling as
/// the wildcard listen socket.
+11 -1
View File
@@ -606,7 +606,17 @@ mod platform {
unsafe { &*(storage as *const _ as *const libc::sockaddr_in6) };
let ip = std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr);
let port = u16::from_be(addr.sin6_port);
Ok(SocketAddr::from((ip, port)))
// Preserve sin6_scope_id: a link-local source (fe80::/10) is
// only routable together with its interface scope, so dropping
// it here breaks replies to a link-local peer (e.g. a Wi-Fi
// Aware NDP interface). Non-link-local sources carry scope 0,
// for which SocketAddrV6 is identical to the unscoped form.
Ok(SocketAddr::V6(std::net::SocketAddrV6::new(
ip,
port,
0,
addr.sin6_scope_id,
)))
}
family => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,