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
Generated
+88 -1
View File
@@ -529,6 +529,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "critical-section"
version = "1.2.0"
@@ -810,6 +825,21 @@ dependencies = [
"winapi",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "3.0.0"
@@ -919,9 +949,11 @@ dependencies = [
"anyhow",
"blake3",
"data-encoding",
"futures",
"iroh",
"iroh-base",
"iroh-tickets",
"mainline",
"postcard",
"rand 0.9.4",
"serde",
@@ -955,6 +987,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flume"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
dependencies = [
"futures-core",
"futures-sink",
"spin 0.9.8",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1793,7 +1836,7 @@ dependencies = [
"iroh-base",
"iroh-dns",
"iroh-metrics",
"lru",
"lru 0.18.1",
"n0-error",
"n0-future",
"noq",
@@ -1965,6 +2008,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1993,6 +2042,12 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "lru"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
name = "lru"
version = "0.18.1"
@@ -2014,6 +2069,28 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
[[package]]
name = "mainline"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dfad9601b9117218868271c05403853593d4817e7abeab5c95e11eee16382bb"
dependencies = [
"crc",
"document-features",
"dyn-clone",
"ed25519-dalek",
"flume",
"futures-lite",
"getrandom 0.4.3",
"lru 0.16.4",
"serde",
"serde_bencode",
"serde_bytes",
"sha1_smol",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3161,6 +3238,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_bencode"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e"
dependencies = [
"serde",
"serde_bytes",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
+1
View File
@@ -17,6 +17,7 @@ federation-net = { path = "crates/federation-net" }
iroh = "1"
iroh-base = "1"
iroh-tickets = "1"
mainline = "7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "io-std", "signal"] }
serde = { version = "1", features = ["derive"] }
postcard = { version = "1", features = ["alloc"] }
+32 -4
View File
@@ -13,6 +13,9 @@ as an opaque serde-serializable payload.
* Persistent peer identity (`<data_dir>/identity.key`, created on first start).
* Connection establishment via a shareable string ticket (`fnet...`).
* Optional automatic peer discovery (**rendezvous**): peers of a network find
each other through the BitTorrent Mainline DHT knowing nothing but the
network id — no tickets and no bootstrap servers.
* An application-level handshake that isolates networks and schemas.
* Typed message exchange in both directions over one QUIC connection.
* Network events (connect, disconnect, message, protocol error).
@@ -20,10 +23,9 @@ as an opaque serde-serializable payload.
## What it deliberately does not do (yet)
No global peer discovery, gossip, broadcast overlay, DHT, content search,
file/chunk/streaming transfer, database sync, CRDTs, authorization, ACLs,
HTTP APIs or metrics. The architecture allows adding these later as separate
modules or ALPN protocols.
No gossip, broadcast overlay, content search, file/chunk/streaming transfer,
database sync, CRDTs, authorization, ACLs, HTTP APIs or metrics. The
architecture allows adding these later as separate modules or ALPN protocols.
## Usage
@@ -126,6 +128,32 @@ reject each other, because they could not decode each other's messages. Any
backwards-incompatible change to your message type requires a new schema
name.
## Rendezvous (peer discovery by network id)
Passing a `RendezvousConfig` to the config builder enables automatic peer
discovery: every peer periodically publishes its own Iroh address into a
shared BEP44 mutable record in the public BitTorrent Mainline DHT and dials
the addresses other peers published there. The record's signing key is
derived deterministically from the `NetworkId`, so knowing the network id is
enough to find and join the network; the first peer of a new network simply
publishes itself and waits.
```rust
let config = NetworkConfig::builder()
.data_dir("./peer-a")
.network_id(NetworkId::from_name("example-network"))
.schema_id(SchemaId::from_name("demo-message-v1"))
.rendezvous(federation_net::RendezvousConfig::default())
.build()?;
```
Because the rendezvous record is world-readable, 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 (e.g.
`myorg-prod-7f3a`); the handshake still rejects peers whose network id does
not match exactly. Rendezvous is off by default — without it, peers are
connected via tickets only.
## Tickets
A `PeerTicket` is a self-contained invitation string with the `fnet` prefix
+21 -6
View File
@@ -8,7 +8,8 @@ use std::sync::mpsc as std_mpsc;
use anyhow::Context;
use artist_dht::{
Artist, ArtistDhtConfig, ArtistDhtEvent, ArtistDhtService, NetworkId, PeerTicket, SearchOutcome,
Artist, ArtistDhtConfig, ArtistDhtEvent, ArtistDhtService, NetworkId, PeerTicket,
RendezvousConfig, SearchOutcome,
};
use clap::Parser;
use rustyline::ExternalPrinter;
@@ -30,9 +31,16 @@ struct Args {
name: String,
/// Ticket(s) of peers to connect to on startup; may be repeated.
/// Optional: peers of the same network normally find each other
/// automatically through the mainline DHT.
#[arg(long)]
connect: Vec<String>,
/// Disable automatic peer discovery through the mainline DHT; only the
/// tickets given via --connect are used.
#[arg(long)]
no_bootstrap: bool,
/// Enable verbose logging.
#[arg(long)]
verbose: bool,
@@ -61,11 +69,13 @@ async fn main() -> anyhow::Result<()> {
.with_writer(std::io::stderr)
.init();
let config = ArtistDhtConfig::builder()
let mut config_builder = ArtistDhtConfig::builder()
.data_dir(&args.data_dir)
.network_id(NetworkId::from_name(&args.network_id))
.build()
.context("invalid configuration")?;
.network_id(NetworkId::from_name(&args.network_id));
if !args.no_bootstrap {
config_builder = config_builder.rendezvous(RendezvousConfig::default());
}
let config = config_builder.build().context("invalid configuration")?;
let (service, mut events) = ArtistDhtService::start(config)
.await
@@ -96,7 +106,12 @@ async fn main() -> anyhow::Result<()> {
}
}
}
if args.connect.is_empty() {
if !args.no_bootstrap {
println!(
"Discovering '{}' peers via the mainline DHT... (may take up to a minute)",
args.network_id
);
} else if args.connect.is_empty() {
println!("Waiting for peers... (share the ticket above)");
}
println!("Type /help for commands.");
+32 -19
View File
@@ -21,7 +21,10 @@ running peers itself.
* Each peer has a stable 256-bit `NodeId` derived from its persistent
`federation-net` endpoint id; records live on the `K = 8` XOR-closest
nodes.
* Peers discover each other through automatic Hello/PeerExchange gossip;
* A peer joins the network knowing **only the network id**: on startup it
finds other peers through a rendezvous record in the public BitTorrent
Mainline DHT (see `federation-net`'s rendezvous). Within the network peers
then discover each other through automatic Hello/PeerExchange gossip;
connections to newly learned peers are opened **on demand** from stored
tickets.
* Deletions propagate as **tombstones** (revision-based, they beat active
@@ -33,16 +36,29 @@ running peers itself.
Out of scope (by design): fuzzy search, content transfer, CRDTs, consensus,
signatures on DHT records, Sybil protection, accounts, GUI.
## Bootstrapping limitation
## Bootstrapping
There is deliberately **no bootstrap server**, so a peer cannot join a
network it has no contact in: you must pass the ticket of *any* already
running peer via `--connect`. That first peer has no special role — once
contacts have spread through peer exchange, it can be switched off.
There is deliberately **no bootstrap server**. A peer joins a network knowing
only its `--network-id`: peers of the same network find each other through a
shared rendezvous record in the public BitTorrent Mainline DHT (the record's
signing key is derived from the network id). The first peer of a new network
simply publishes itself and waits; every later peer with the same id finds it
within a rendezvous round or two (typically well under a minute). No peer has
a special role.
Because the rendezvous record is world-readable, the network id acts as a
public rendezvous token: anyone who knows it can discover and join the
network. Pick a unique, hard-to-guess name for a private network (e.g.
`myband-artists-prod-7f3a`).
Discovery can be turned off with `--no-bootstrap`; then a peer can only join
through the ticket of *any* already running peer passed via `--connect`
(tickets also work in addition to discovery, e.g. on isolated networks
without internet access).
## Running the demo (three peers)
Start Peer A (alice):
Start the peers in any order — they only share the network id:
```bash
cargo run -p artist-dht-cli -- \
@@ -51,30 +67,25 @@ cargo run -p artist-dht-cli -- \
--name alice
```
Copy the printed `Ticket: fnet...`. Start Peer B (bob) with it:
```bash
cargo run -p artist-dht-cli -- \
--data-dir ./peer-b \
--network-id demo-artists \
--name bob \
--connect 'fnet...'
--name bob
```
Start Peer C (charlie), connected **only to A** — it will learn about B via
peer exchange:
```bash
cargo run -p artist-dht-cli -- \
--data-dir ./peer-c \
--network-id demo-artists \
--name charlie \
--connect 'fnet...'
--name charlie
```
`--connect` may be repeated to dial several peers. All three processes must
use the same `--network-id`; a peer from another network is rejected during
the transport handshake.
Within a minute `Peer connected: ...` lines appear on all three. All
processes must use the same `--network-id`; a peer from another network is
rejected during the transport handshake. (With `--no-bootstrap`, pass the
ticket printed by a running peer via `--connect 'fnet...'` instead;
`--connect` may be repeated.)
### Demo scenario
@@ -130,6 +141,8 @@ use federation_net::NetworkId;
let config = ArtistDhtConfig::builder()
.data_dir("./peer-a")
.network_id(NetworkId::from_name("demo-artists"))
// Optional: discover peers of this network via the mainline DHT.
.rendezvous(artist_dht::RendezvousConfig::default())
.build()?;
let (service, mut events) = ArtistDhtService::start(config).await?;
+13 -1
View File
@@ -3,7 +3,7 @@
use std::path::PathBuf;
use std::time::Duration;
use federation_net::NetworkId;
use federation_net::{NetworkId, RendezvousConfig};
use crate::error::{ArtistDhtError, Result};
@@ -40,6 +40,10 @@ pub struct ArtistDhtConfig {
/// establishing a connection through relays can take much longer than a
/// request over an existing one.
pub transport_timeout: Duration,
/// Automatic peer discovery over the mainline DHT: peers of the same
/// network find each other knowing nothing but the network id. `None`
/// disables it; peers are then connected via tickets only.
pub rendezvous: Option<RendezvousConfig>,
}
impl ArtistDhtConfig {
@@ -62,6 +66,7 @@ pub struct ArtistDhtConfigBuilder {
request_timeout: Option<Duration>,
lookup_timeout: Option<Duration>,
transport_timeout: Option<Duration>,
rendezvous: Option<RendezvousConfig>,
}
impl ArtistDhtConfigBuilder {
@@ -107,6 +112,12 @@ impl ArtistDhtConfigBuilder {
self
}
/// Enables automatic peer discovery over the mainline DHT.
pub fn rendezvous(mut self, rendezvous: RendezvousConfig) -> Self {
self.rendezvous = Some(rendezvous);
self
}
/// Validates and builds the configuration.
pub fn build(self) -> Result<ArtistDhtConfig> {
let data_dir = self
@@ -131,6 +142,7 @@ impl ArtistDhtConfigBuilder {
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
lookup_timeout: self.lookup_timeout.unwrap_or(DEFAULT_LOOKUP_TIMEOUT),
transport_timeout: self.transport_timeout.unwrap_or(DEFAULT_TRANSPORT_TIMEOUT),
rendezvous: self.rendezvous,
};
for (name, value) in [
("republish_interval", config.republish_interval),
+1 -1
View File
@@ -89,4 +89,4 @@ pub use service::{
};
// Re-exported types from the transport layer that appear in this API.
pub use federation_net::{EndpointId, NetworkId, PeerTicket};
pub use federation_net::{EndpointId, NetworkId, PeerTicket, RendezvousConfig};
+6 -2
View File
@@ -106,11 +106,15 @@ impl ArtistDhtService {
/// Starts the service: opens the database, starts the network engine,
/// loads persisted contacts and spawns the maintenance tasks.
pub async fn start(config: ArtistDhtConfig) -> Result<(Self, ArtistDhtEventReceiver)> {
let engine_config = NetworkConfig::builder()
let mut engine_builder = NetworkConfig::builder()
.data_dir(&config.data_dir)
.network_id(config.network_id)
.schema_id(SchemaId::from_name(SCHEMA_NAME))
.request_timeout(config.transport_timeout)
.request_timeout(config.transport_timeout);
if let Some(rendezvous) = config.rendezvous.clone() {
engine_builder = engine_builder.rendezvous(rendezvous);
}
let engine_config = engine_builder
.build()
.map_err(|err| ArtistDhtError::Network(err.to_string()))?;
let (engine, net_events) = NetworkEngine::start(engine_config).await?;
+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");
}