Added randezvous

This commit is contained in:
Ultradesu
2026-07-16 16:04:05 +03:00
parent 52865470b6
commit 747ed7a3e9
14 changed files with 788 additions and 81 deletions
+3
View File
@@ -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"] }
+49
View File
@@ -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());
}
}
+130 -46
View File
@@ -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)
+5
View File
@@ -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.
+344
View File
@@ -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);
}
}
+63 -1
View File
@@ -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");
}