Added randezvous
This commit is contained in:
@@ -10,6 +10,8 @@ rust-version.workspace = true
|
||||
iroh = { workspace = true }
|
||||
iroh-base = { workspace = true }
|
||||
iroh-tickets = { workspace = true }
|
||||
mainline = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
@@ -21,5 +23,6 @@ rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
mainline = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::error::{NetworkError, Result};
|
||||
use crate::protocol::{NetworkId, SchemaId};
|
||||
use crate::rendezvous::RendezvousConfig;
|
||||
|
||||
/// Default maximum size of an encoded domain message (256 KiB).
|
||||
pub const DEFAULT_MAX_MESSAGE_SIZE: usize = 256 * 1024;
|
||||
@@ -34,6 +35,9 @@ pub struct NetworkConfig {
|
||||
pub event_channel_capacity: usize,
|
||||
/// Maximum number of concurrently processed streams per peer.
|
||||
pub max_concurrent_streams_per_peer: usize,
|
||||
/// Automatic peer discovery over the mainline DHT; `None` disables it and
|
||||
/// peers are connected via tickets only.
|
||||
pub rendezvous: Option<RendezvousConfig>,
|
||||
}
|
||||
|
||||
impl NetworkConfig {
|
||||
@@ -56,6 +60,7 @@ pub struct NetworkConfigBuilder {
|
||||
request_timeout: Option<Duration>,
|
||||
event_channel_capacity: Option<usize>,
|
||||
max_concurrent_streams_per_peer: Option<usize>,
|
||||
rendezvous: Option<RendezvousConfig>,
|
||||
}
|
||||
|
||||
impl NetworkConfigBuilder {
|
||||
@@ -101,6 +106,12 @@ impl NetworkConfigBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables automatic peer discovery over the mainline DHT.
|
||||
pub fn rendezvous(mut self, rendezvous: RendezvousConfig) -> Self {
|
||||
self.rendezvous = Some(rendezvous);
|
||||
self
|
||||
}
|
||||
|
||||
/// Validates the configuration and builds a [`NetworkConfig`].
|
||||
pub fn build(self) -> Result<NetworkConfig> {
|
||||
let data_dir = self
|
||||
@@ -156,6 +167,19 @@ impl NetworkConfigBuilder {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(rendezvous) = &self.rendezvous {
|
||||
if rendezvous.interval.is_zero() {
|
||||
return Err(NetworkError::InvalidConfig(
|
||||
"rendezvous interval must be greater than zero".into(),
|
||||
));
|
||||
}
|
||||
if rendezvous.entry_ttl.is_zero() {
|
||||
return Err(NetworkError::InvalidConfig(
|
||||
"rendezvous entry_ttl must be greater than zero".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NetworkConfig {
|
||||
data_dir,
|
||||
network_id,
|
||||
@@ -164,6 +188,7 @@ impl NetworkConfigBuilder {
|
||||
request_timeout,
|
||||
event_channel_capacity,
|
||||
max_concurrent_streams_per_peer,
|
||||
rendezvous: self.rendezvous,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -223,4 +248,28 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendezvous_is_disabled_by_default_and_validated_when_set() {
|
||||
let config = base_builder().build().expect("valid config");
|
||||
assert!(config.rendezvous.is_none());
|
||||
|
||||
let config = base_builder()
|
||||
.rendezvous(RendezvousConfig::default())
|
||||
.build()
|
||||
.expect("valid config");
|
||||
assert!(config.rendezvous.is_some());
|
||||
|
||||
let zero_interval = RendezvousConfig {
|
||||
interval: Duration::ZERO,
|
||||
..RendezvousConfig::default()
|
||||
};
|
||||
assert!(base_builder().rendezvous(zero_interval).build().is_err());
|
||||
|
||||
let zero_ttl = RendezvousConfig {
|
||||
entry_ttl: Duration::ZERO,
|
||||
..RendezvousConfig::default()
|
||||
};
|
||||
assert!(base_builder().rendezvous(zero_ttl).build().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ use std::time::Duration;
|
||||
|
||||
use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt, presets};
|
||||
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::{Semaphore, mpsc};
|
||||
use tokio::sync::{Semaphore, mpsc, watch};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -24,6 +24,7 @@ use crate::protocol::{
|
||||
ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE,
|
||||
MessageRejectReason, MessageRequest, MessageResponse, NetworkId, PROTOCOL_VERSION, SchemaId,
|
||||
};
|
||||
use crate::rendezvous::{RendezvousClient, RendezvousConfig, now_ms};
|
||||
use crate::ticket::{PeerTicket, TICKET_VERSION};
|
||||
use crate::wire;
|
||||
|
||||
@@ -47,6 +48,8 @@ const REJECT_LINGER: Duration = Duration::from_secs(3);
|
||||
/// How long [`NetworkEngine::shutdown`] waits for background tasks before
|
||||
/// aborting them.
|
||||
const SHUTDOWN_TASK_GRACE: Duration = Duration::from_secs(5);
|
||||
/// Upper bound of the rendezvous round delay while no peer is connected yet.
|
||||
const RENDEZVOUS_LONELY_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Bounds required of a domain message type.
|
||||
///
|
||||
@@ -85,6 +88,9 @@ struct Shared<M> {
|
||||
tasks: Mutex<JoinSet<()>>,
|
||||
next_generation: AtomicU64,
|
||||
shutting_down: AtomicBool,
|
||||
/// Broadcasts the start of the shutdown to long-running background
|
||||
/// loops so they can stop promptly instead of being aborted.
|
||||
shutdown_signal: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl<M: Message> Shared<M> {
|
||||
@@ -396,6 +402,52 @@ impl<M: Message> Shared<M> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dials `addr`, runs the client handshake and registers the connection.
|
||||
///
|
||||
/// An existing healthy connection to the same peer is reused. The remote
|
||||
/// identity is taken from the authenticated Iroh connection, never from
|
||||
/// `addr` itself.
|
||||
async fn connect_to_addr(self: &Arc<Self>, addr: EndpointAddr) -> Result<EndpointId> {
|
||||
self.ensure_running()?;
|
||||
let target = addr.id;
|
||||
if lock(&self.peers).contains_key(&target) {
|
||||
debug!(peer = %target, "reusing existing connection");
|
||||
return Ok(target);
|
||||
}
|
||||
|
||||
let request_timeout = self.config.request_timeout;
|
||||
let connection = timeout(request_timeout, self.endpoint.connect(addr, ALPN))
|
||||
.await
|
||||
.map_err(|_| NetworkError::Timeout)?
|
||||
.map_err(|err| NetworkError::Transport(format!("failed to connect: {err}")))?;
|
||||
let peer_id = connection.remote_id();
|
||||
|
||||
let handshake = timeout(request_timeout, self.run_client_handshake(&connection))
|
||||
.await
|
||||
.map_err(|_| NetworkError::Timeout)
|
||||
.and_then(|res| res);
|
||||
if let Err(err) = handshake {
|
||||
connection.close(CLOSE_CODE_HANDSHAKE_REJECTED, b"handshake failed");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let generation =
|
||||
self.register_connection(peer_id, connection.clone(), ConnectionDirection::Outgoing);
|
||||
info!(peer = %peer_id, "peer connected (outgoing)");
|
||||
self.emit(NetworkEvent::PeerConnected {
|
||||
peer_id,
|
||||
direction: ConnectionDirection::Outgoing,
|
||||
})
|
||||
.await;
|
||||
let loop_shared = self.clone();
|
||||
self.spawn_task(async move {
|
||||
loop_shared
|
||||
.connection_loop(peer_id, connection, generation)
|
||||
.await;
|
||||
});
|
||||
Ok(peer_id)
|
||||
}
|
||||
|
||||
/// Runs the client side of the handshake on a fresh outgoing connection.
|
||||
async fn run_client_handshake(&self, connection: &Connection) -> Result<()> {
|
||||
let (mut send, mut recv) = connection.open_bi().await.map_err(|err| {
|
||||
@@ -418,6 +470,65 @@ impl<M: Message> Shared<M> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodically publishes this peer to the network's rendezvous record and
|
||||
/// dials every other peer found there.
|
||||
///
|
||||
/// Failures of a single round or dial are logged and retried on the next
|
||||
/// round; the loop only ends when the engine shuts down (the task is
|
||||
/// aborted).
|
||||
async fn rendezvous_loop<M: Message>(
|
||||
shared: Arc<Shared<M>>,
|
||||
client: RendezvousClient,
|
||||
config: RendezvousConfig,
|
||||
) {
|
||||
let mut shutdown = shared.shutdown_signal.subscribe();
|
||||
// Give the endpoint a moment to learn its relay and direct addresses so
|
||||
// the very first published record is already dialable.
|
||||
let _ = timeout(shared.config.request_timeout, shared.endpoint.online()).await;
|
||||
let self_id = shared.endpoint.id();
|
||||
loop {
|
||||
if shared.is_shutting_down() {
|
||||
return;
|
||||
}
|
||||
let round = async {
|
||||
let addr = shared.endpoint.addr();
|
||||
let self_addr = (!addr.is_empty()).then_some(addr);
|
||||
match client.round(self_addr, now_ms()).await {
|
||||
Ok(peers) => {
|
||||
for peer_addr in peers {
|
||||
let peer = peer_addr.id;
|
||||
if peer == self_id || lock(&shared.peers).contains_key(&peer) {
|
||||
continue;
|
||||
}
|
||||
debug!(peer = %peer, "rendezvous discovered a peer; connecting");
|
||||
if let Err(err) = shared.connect_to_addr(peer_addr).await {
|
||||
// Stale entries (peers that left) fail here; they
|
||||
// age out of the record by TTL.
|
||||
debug!(peer = %peer, error = %err, "rendezvous connect attempt failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => debug!(error = %err, "rendezvous round failed"),
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = round => {}
|
||||
_ = shutdown.changed() => return,
|
||||
}
|
||||
// Poll faster while the peer is still alone: joining a network
|
||||
// should not have to wait a full interval for the next round.
|
||||
let delay = if lock(&shared.peers).is_empty() {
|
||||
config.interval.min(RENDEZVOUS_LONELY_INTERVAL)
|
||||
} else {
|
||||
config.interval
|
||||
};
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
_ = shutdown.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handshake_code_to_error(code: HandshakeErrorCode) -> NetworkError {
|
||||
match code {
|
||||
HandshakeErrorCode::UnsupportedProtocolVersion => NetworkError::UnsupportedProtocolVersion,
|
||||
@@ -506,12 +617,26 @@ impl<M: Message> NetworkEngine<M> {
|
||||
tasks: Mutex::new(JoinSet::new()),
|
||||
next_generation: AtomicU64::new(0),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
shutdown_signal: watch::Sender::new(false),
|
||||
});
|
||||
let handler = FederationProtocol {
|
||||
shared: Arc::downgrade(&shared),
|
||||
};
|
||||
let router = Router::builder(endpoint).accept(ALPN, handler).spawn();
|
||||
*lock(&shared.router) = Some(router);
|
||||
if let Some(rendezvous) = shared.config.rendezvous.clone() {
|
||||
match RendezvousClient::new(shared.config.network_id, &rendezvous) {
|
||||
Ok(client) => {
|
||||
let loop_shared = shared.clone();
|
||||
shared.spawn_task(async move {
|
||||
rendezvous_loop(loop_shared, client, rendezvous).await;
|
||||
});
|
||||
}
|
||||
// Rendezvous is a convenience; the engine stays usable via
|
||||
// tickets even when the DHT client cannot start.
|
||||
Err(err) => warn!(error = %err, "peer rendezvous disabled"),
|
||||
}
|
||||
}
|
||||
info!(
|
||||
endpoint_id = %endpoint_id,
|
||||
network_id = %shared.config.network_id,
|
||||
@@ -587,54 +712,12 @@ impl<M: Message> NetworkEngine<M> {
|
||||
if ticket.schema_id != shared.config.schema_id {
|
||||
return Err(NetworkError::SchemaMismatch);
|
||||
}
|
||||
let target = ticket.endpoint_id();
|
||||
if target == self.endpoint_id() {
|
||||
if ticket.endpoint_id() == self.endpoint_id() {
|
||||
return Err(NetworkError::InvalidTicket(
|
||||
"the ticket points to this peer itself".to_string(),
|
||||
));
|
||||
}
|
||||
if lock(&shared.peers).contains_key(&target) {
|
||||
debug!(peer = %target, "reusing existing connection");
|
||||
return Ok(target);
|
||||
}
|
||||
|
||||
let request_timeout = shared.config.request_timeout;
|
||||
let connection = timeout(
|
||||
request_timeout,
|
||||
shared.endpoint.connect(ticket.endpoint_addr, ALPN),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| NetworkError::Timeout)?
|
||||
.map_err(|err| NetworkError::Transport(format!("failed to connect: {err}")))?;
|
||||
// The remote identity comes from the authenticated Iroh connection,
|
||||
// never from the ticket payload.
|
||||
let peer_id = connection.remote_id();
|
||||
|
||||
let handshake = timeout(request_timeout, shared.run_client_handshake(&connection))
|
||||
.await
|
||||
.map_err(|_| NetworkError::Timeout)
|
||||
.and_then(|res| res);
|
||||
if let Err(err) = handshake {
|
||||
connection.close(CLOSE_CODE_HANDSHAKE_REJECTED, b"handshake failed");
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let generation =
|
||||
shared.register_connection(peer_id, connection.clone(), ConnectionDirection::Outgoing);
|
||||
info!(peer = %peer_id, "peer connected (outgoing)");
|
||||
shared
|
||||
.emit(NetworkEvent::PeerConnected {
|
||||
peer_id,
|
||||
direction: ConnectionDirection::Outgoing,
|
||||
})
|
||||
.await;
|
||||
let loop_shared = shared.clone();
|
||||
shared.spawn_task(async move {
|
||||
loop_shared
|
||||
.connection_loop(peer_id, connection, generation)
|
||||
.await;
|
||||
});
|
||||
Ok(peer_id)
|
||||
shared.connect_to_addr(ticket.endpoint_addr).await
|
||||
}
|
||||
|
||||
/// Sends a domain message to a connected peer.
|
||||
@@ -719,6 +802,7 @@ impl<M: Message> NetworkEngine<M> {
|
||||
return Ok(());
|
||||
}
|
||||
info!("network engine shutting down");
|
||||
let _ = shared.shutdown_signal.send(true);
|
||||
|
||||
// Close all active connections.
|
||||
let states: Vec<PeerState> = lock(&shared.peers)
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
//! * [`NetworkId`] — isolates independent P2P networks from each other.
|
||||
//! * [`SchemaId`] — isolates applications with incompatible message schemas.
|
||||
//! * [`PeerTicket`] — a shareable string invitation used to reach a peer.
|
||||
//! * [`RendezvousConfig`] — optional automatic peer discovery: peers of a
|
||||
//! network find each other through the BitTorrent Mainline DHT knowing
|
||||
//! nothing but the network id.
|
||||
//! * [`NetworkEngine`] — the engine itself; [`NetworkEventReceiver`] delivers
|
||||
//! [`NetworkEvent`]s to the application.
|
||||
//!
|
||||
@@ -54,6 +57,7 @@ mod error;
|
||||
mod event;
|
||||
mod identity;
|
||||
mod protocol;
|
||||
mod rendezvous;
|
||||
mod ticket;
|
||||
mod wire;
|
||||
|
||||
@@ -65,6 +69,7 @@ pub use engine::{Message, NetworkEngine};
|
||||
pub use error::{NetworkError, Result};
|
||||
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
|
||||
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
|
||||
pub use rendezvous::{DEFAULT_RENDEZVOUS_ENTRY_TTL, DEFAULT_RENDEZVOUS_INTERVAL, RendezvousConfig};
|
||||
pub use ticket::{PeerTicket, TICKET_VERSION};
|
||||
|
||||
// Re-exported Iroh types that appear in the public API.
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Automatic peer discovery ("rendezvous") over the BitTorrent Mainline DHT.
|
||||
//!
|
||||
//! Every peer of a network periodically publishes its own [`EndpointAddr`]
|
||||
//! into a shared BEP44 mutable record whose signing key is derived from the
|
||||
//! [`NetworkId`], and reads the addresses other peers published there.
|
||||
//! Knowing the network id is therefore enough to find and join the network —
|
||||
//! no tickets and no dedicated bootstrap servers are required.
|
||||
//!
|
||||
//! The record lives in the public Mainline DHT (the same network BitTorrent
|
||||
//! and pkarr use), so the mechanism needs no infrastructure of its own. The
|
||||
//! flip side is that the network id acts as a public rendezvous token: anyone
|
||||
//! who knows it can discover and join the network. Treat the id of a private
|
||||
//! network like a shared secret; the application-level handshake still
|
||||
//! rejects peers whose network id does not match exactly.
|
||||
//!
|
||||
//! Multiple peers write the same record concurrently. Writers merge every
|
||||
//! record instance they can read before publishing, so a lost update leaves
|
||||
//! at most a temporarily incomplete peer list — it converges as everyone
|
||||
//! republishes — and one reachable entry is enough to join, because contact
|
||||
//! exchange takes over after the first connection.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use futures::StreamExt;
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use mainline::async_dht::AsyncDht;
|
||||
use mainline::{Dht, MutableItem, SigningKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::error::{NetworkError, Result};
|
||||
use crate::protocol::NetworkId;
|
||||
|
||||
/// Default interval between rendezvous rounds (read + publish).
|
||||
pub const DEFAULT_RENDEZVOUS_INTERVAL: Duration = Duration::from_secs(60);
|
||||
/// Default time after which a published peer entry is considered stale.
|
||||
pub const DEFAULT_RENDEZVOUS_ENTRY_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// Version of the rendezvous record wire format.
|
||||
const RECORD_VERSION: u16 = 1;
|
||||
/// Domain separation context for deriving the record signing key.
|
||||
const KEY_DERIVATION_CONTEXT: &str = "federation-net:rendezvous:v1";
|
||||
/// BEP44 caps mutable values at 1000 bytes; stay safely below.
|
||||
const MAX_RECORD_BYTES: usize = 900;
|
||||
|
||||
/// Configuration of the mainline-DHT rendezvous.
|
||||
///
|
||||
/// Passing a `RendezvousConfig` to
|
||||
/// [`crate::NetworkConfigBuilder::rendezvous`] enables automatic peer
|
||||
/// discovery; by default it is disabled and peers are connected via tickets.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RendezvousConfig {
|
||||
/// Interval between rendezvous rounds. Each round reads the record,
|
||||
/// republishes it with this peer merged in and dials newly seen peers.
|
||||
pub interval: Duration,
|
||||
/// Entries older than this are dropped from the record.
|
||||
pub entry_ttl: Duration,
|
||||
/// Overrides the DHT bootstrap nodes (`host:port` strings). `None` uses
|
||||
/// the public mainline defaults; tests point this at a local testnet.
|
||||
pub dht_bootstrap: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for RendezvousConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interval: DEFAULT_RENDEZVOUS_INTERVAL,
|
||||
entry_ttl: DEFAULT_RENDEZVOUS_ENTRY_TTL,
|
||||
dht_bootstrap: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One published peer: its dialable address and when it was last refreshed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct RendezvousEntry {
|
||||
addr: EndpointAddr,
|
||||
last_seen_ms: u64,
|
||||
}
|
||||
|
||||
/// The record stored in the mainline DHT: a bounded list of recent peers.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RendezvousRecord {
|
||||
version: u16,
|
||||
entries: Vec<RendezvousEntry>,
|
||||
}
|
||||
|
||||
/// Derives the shared record signing key from the network id.
|
||||
///
|
||||
/// The derivation is deterministic, so every member of the network can both
|
||||
/// read and update the record.
|
||||
fn signing_key(network_id: NetworkId) -> SigningKey {
|
||||
let seed = blake3::derive_key(KEY_DERIVATION_CONTEXT, network_id.as_bytes());
|
||||
SigningKey::from_bytes(&seed)
|
||||
}
|
||||
|
||||
/// Current unix time in milliseconds.
|
||||
pub(crate) fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn decode_record(bytes: &[u8]) -> Option<Vec<RendezvousEntry>> {
|
||||
let record: RendezvousRecord = postcard::from_bytes(bytes).ok()?;
|
||||
(record.version == RECORD_VERSION).then_some(record.entries)
|
||||
}
|
||||
|
||||
/// Merges entries from every record instance seen this round with our own,
|
||||
/// deduplicating by endpoint id (newest wins) and dropping stale entries.
|
||||
fn merge_entries(
|
||||
seen: Vec<RendezvousEntry>,
|
||||
self_entry: Option<RendezvousEntry>,
|
||||
now_ms: u64,
|
||||
entry_ttl: Duration,
|
||||
) -> Vec<RendezvousEntry> {
|
||||
let ttl_ms = entry_ttl.as_millis() as u64;
|
||||
let mut by_id: HashMap<EndpointId, RendezvousEntry> = HashMap::new();
|
||||
for mut entry in seen.into_iter().chain(self_entry) {
|
||||
// A peer with a fast clock must not pin its entry into the future.
|
||||
entry.last_seen_ms = entry.last_seen_ms.min(now_ms);
|
||||
if entry.last_seen_ms + ttl_ms <= now_ms {
|
||||
continue;
|
||||
}
|
||||
match by_id.get(&entry.addr.id) {
|
||||
Some(existing) if existing.last_seen_ms >= entry.last_seen_ms => {}
|
||||
_ => {
|
||||
by_id.insert(entry.addr.id, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut entries: Vec<RendezvousEntry> = by_id.into_values().collect();
|
||||
entries.sort_by(|a, b| {
|
||||
b.last_seen_ms
|
||||
.cmp(&a.last_seen_ms)
|
||||
.then_with(|| a.addr.id.cmp(&b.addr.id))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
/// Encodes the record, dropping the oldest entries until it fits the BEP44
|
||||
/// value size limit.
|
||||
fn encode_record_capped(mut entries: Vec<RendezvousEntry>) -> Result<Vec<u8>> {
|
||||
loop {
|
||||
let record = RendezvousRecord {
|
||||
version: RECORD_VERSION,
|
||||
entries,
|
||||
};
|
||||
let encoded = postcard::to_stdvec(&record).map_err(|err| {
|
||||
NetworkError::Serialization(format!("failed to encode rendezvous record: {err}"))
|
||||
})?;
|
||||
if encoded.len() <= MAX_RECORD_BYTES {
|
||||
return Ok(encoded);
|
||||
}
|
||||
entries = record.entries;
|
||||
if entries.pop().is_none() {
|
||||
return Err(NetworkError::Serialization(
|
||||
"rendezvous record does not fit even when empty".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A client of the shared rendezvous record of one network.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RendezvousClient {
|
||||
dht: AsyncDht,
|
||||
key: SigningKey,
|
||||
entry_ttl: Duration,
|
||||
}
|
||||
|
||||
impl RendezvousClient {
|
||||
/// Binds a mainline DHT client for the rendezvous record of `network_id`.
|
||||
pub(crate) fn new(network_id: NetworkId, config: &RendezvousConfig) -> Result<Self> {
|
||||
let mut builder = Dht::builder();
|
||||
if let Some(nodes) = &config.dht_bootstrap {
|
||||
builder.bootstrap(nodes);
|
||||
}
|
||||
let dht = builder
|
||||
.build()
|
||||
.map_err(|err| {
|
||||
NetworkError::Transport(format!("failed to start mainline DHT client: {err}"))
|
||||
})?
|
||||
.as_async();
|
||||
Ok(Self {
|
||||
dht,
|
||||
key: signing_key(network_id),
|
||||
entry_ttl: config.entry_ttl,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs one rendezvous round: reads every reachable instance of the
|
||||
/// record, merges them with our own address, republishes the result and
|
||||
/// returns the addresses of the other known peers.
|
||||
///
|
||||
/// `self_addr` is `None` while the local endpoint does not know any
|
||||
/// dialable address yet; the round then only reads.
|
||||
pub(crate) async fn round(
|
||||
&self,
|
||||
self_addr: Option<EndpointAddr>,
|
||||
now_ms: u64,
|
||||
) -> Result<Vec<EndpointAddr>> {
|
||||
let public_key = self.key.verifying_key().to_bytes();
|
||||
let mut items = self.dht.get_mutable(&public_key, None, None);
|
||||
let mut seen = Vec::new();
|
||||
let mut max_seq = 0i64;
|
||||
while let Some(item) = items.next().await {
|
||||
max_seq = max_seq.max(item.seq());
|
||||
match decode_record(item.value()) {
|
||||
Some(entries) => seen.extend(entries),
|
||||
None => debug!("ignoring malformed rendezvous record instance"),
|
||||
}
|
||||
}
|
||||
|
||||
let self_id = self_addr.as_ref().map(|addr| addr.id);
|
||||
let self_entry = self_addr.map(|addr| RendezvousEntry {
|
||||
addr,
|
||||
last_seen_ms: now_ms,
|
||||
});
|
||||
let publish = self_entry.is_some();
|
||||
let merged = merge_entries(seen, self_entry, now_ms, self.entry_ttl);
|
||||
|
||||
if publish {
|
||||
let encoded = encode_record_capped(merged.clone())?;
|
||||
// Strictly newer than every instance seen this round; concurrent
|
||||
// writers race, but merging on read makes lost updates benign.
|
||||
let item = MutableItem::new(self.key.clone(), &encoded, max_seq + 1, None);
|
||||
if let Err(err) = self.dht.put_mutable(item, None).await {
|
||||
warn!(error = %err, "failed to publish the rendezvous record");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(merged
|
||||
.into_iter()
|
||||
.map(|entry| entry.addr)
|
||||
.filter(|addr| Some(addr.id) != self_id)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use iroh::SecretKey;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn addr(port: u16) -> EndpointAddr {
|
||||
EndpointAddr::new(SecretKey::generate().public())
|
||||
.with_ip_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))
|
||||
}
|
||||
|
||||
fn entry(addr: EndpointAddr, last_seen_ms: u64) -> RendezvousEntry {
|
||||
RendezvousEntry { addr, last_seen_ms }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_key_is_deterministic_and_network_specific() {
|
||||
let a = signing_key(NetworkId::from_name("net-a"));
|
||||
let b = signing_key(NetworkId::from_name("net-a"));
|
||||
let c = signing_key(NetworkId::from_name("net-b"));
|
||||
assert_eq!(a.to_bytes(), b.to_bytes());
|
||||
assert_ne!(a.to_bytes(), c.to_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_round_trips() {
|
||||
let entries = vec![entry(addr(1000), 1), entry(addr(1001), 2)];
|
||||
let encoded = encode_record_capped(entries.clone()).expect("encode");
|
||||
let decoded = decode_record(&encoded).expect("decode");
|
||||
assert_eq!(decoded.len(), entries.len());
|
||||
assert_eq!(decoded[0].addr, entries[0].addr);
|
||||
assert_eq!(decoded[1].last_seen_ms, entries[1].last_seen_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_wrong_version_records_are_ignored() {
|
||||
assert!(decode_record(b"garbage").is_none());
|
||||
let record = RendezvousRecord {
|
||||
version: RECORD_VERSION + 1,
|
||||
entries: vec![],
|
||||
};
|
||||
let encoded = postcard::to_stdvec(&record).expect("encode");
|
||||
assert!(decode_record(&encoded).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_deduplicates_by_id_keeping_the_newest() {
|
||||
let a = addr(1000);
|
||||
let older = entry(a.clone(), 100);
|
||||
let newer = entry(a.clone(), 200);
|
||||
let merged = merge_entries(
|
||||
vec![older, newer],
|
||||
None,
|
||||
1000,
|
||||
Duration::from_millis(10_000),
|
||||
);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].last_seen_ms, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_drops_expired_and_clamps_future_entries() {
|
||||
let now = 100_000;
|
||||
let ttl = Duration::from_millis(1_000);
|
||||
let expired = entry(addr(1000), now - 1_000);
|
||||
let fresh = entry(addr(1001), now - 500);
|
||||
let future = entry(addr(1002), now + 60_000);
|
||||
let merged = merge_entries(vec![expired, fresh, future], None, now, ttl);
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert!(merged.iter().all(|e| e.last_seen_ms <= now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_inserts_self_and_sorts_newest_first() {
|
||||
let self_entry = entry(addr(1000), 300);
|
||||
let other = entry(addr(1001), 200);
|
||||
let merged = merge_entries(
|
||||
vec![other],
|
||||
Some(self_entry.clone()),
|
||||
300,
|
||||
Duration::from_millis(10_000),
|
||||
);
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert_eq!(merged[0].addr.id, self_entry.addr.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_records_are_trimmed_to_the_size_limit() {
|
||||
// Far more entries than can ever fit into one BEP44 value, sorted
|
||||
// newest-first as `merge_entries` produces them.
|
||||
let entries: Vec<RendezvousEntry> = (0..100)
|
||||
.map(|i| entry(addr(1000 + i), 10_000 - i as u64))
|
||||
.collect();
|
||||
let encoded = encode_record_capped(entries).expect("encode");
|
||||
assert!(encoded.len() <= MAX_RECORD_BYTES);
|
||||
let decoded = decode_record(&encoded).expect("decode");
|
||||
assert!(!decoded.is_empty());
|
||||
// Trimming drops from the tail, so the newest entry must survive.
|
||||
assert_eq!(decoded[0].last_seen_ms, 10_000);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use federation_net::{
|
||||
ConnectionDirection, EndpointId, NetworkConfig, NetworkEngine, NetworkError, NetworkEvent,
|
||||
NetworkEventReceiver, NetworkId, PeerTicket, SchemaId,
|
||||
NetworkEventReceiver, NetworkId, PeerTicket, RendezvousConfig, SchemaId,
|
||||
};
|
||||
|
||||
/// Hard cap on every test so a regression can never hang CI.
|
||||
@@ -297,3 +297,65 @@ async fn disconnect_removes_peer() {
|
||||
.await
|
||||
.expect("test timed out");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rendezvous_discovers_peers_without_tickets() {
|
||||
let _net = NET_LOCK.lock().await;
|
||||
tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
// A local mainline DHT testnet replaces the public one, keeping the
|
||||
// rendezvous traffic entirely on this machine.
|
||||
let testnet = mainline::Testnet::builder(8)
|
||||
.build()
|
||||
.expect("mainline testnet");
|
||||
let config = |dir: &Path| {
|
||||
NetworkConfig::builder()
|
||||
.data_dir(dir)
|
||||
.network_id(NetworkId::from_name("rendezvous-test-net"))
|
||||
.schema_id(SchemaId::from_name("test-schema-v1"))
|
||||
.request_timeout(Duration::from_secs(10))
|
||||
.rendezvous(RendezvousConfig {
|
||||
interval: Duration::from_secs(2),
|
||||
dht_bootstrap: Some(testnet.bootstrap.clone()),
|
||||
..RendezvousConfig::default()
|
||||
})
|
||||
.build()
|
||||
.expect("valid test config")
|
||||
};
|
||||
|
||||
let dir_a = tempfile::tempdir().expect("tempdir");
|
||||
let dir_b = tempfile::tempdir().expect("tempdir");
|
||||
let (engine_a, mut events_a): (Engine, Events) = NetworkEngine::start(config(dir_a.path()))
|
||||
.await
|
||||
.expect("engine a starts");
|
||||
let (engine_b, mut events_b): (Engine, Events) = NetworkEngine::start(config(dir_b.path()))
|
||||
.await
|
||||
.expect("engine b starts");
|
||||
|
||||
// No tickets are exchanged: both peers only know the network id and
|
||||
// must find each other through the rendezvous record. Discovery
|
||||
// needs a few rendezvous rounds, so the wait is more generous than
|
||||
// EVENT_TIMEOUT (which the usual helpers apply per event).
|
||||
async fn wait_discovered(events: &mut Events, peer: EndpointId) {
|
||||
loop {
|
||||
match events.recv().await.expect("event channel open") {
|
||||
NetworkEvent::PeerConnected { peer_id, .. } if peer_id == peer => return,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let discovery_timeout = Duration::from_secs(90);
|
||||
tokio::time::timeout(discovery_timeout, async {
|
||||
wait_discovered(&mut events_a, engine_b.endpoint_id()).await;
|
||||
wait_discovered(&mut events_b, engine_a.endpoint_id()).await;
|
||||
})
|
||||
.await
|
||||
.expect("peers did not discover each other via rendezvous");
|
||||
assert_eq!(engine_a.connected_peers(), vec![engine_b.endpoint_id()]);
|
||||
assert_eq!(engine_b.connected_peers(), vec![engine_a.endpoint_id()]);
|
||||
|
||||
engine_a.shutdown().await.expect("shutdown a");
|
||||
engine_b.shutdown().await.expect("shutdown b");
|
||||
})
|
||||
.await
|
||||
.expect("test timed out");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user