Added randezvous
This commit is contained in:
Generated
+88
-1
@@ -529,6 +529,21 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "critical-section"
|
name = "critical-section"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -810,6 +825,21 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
@@ -919,9 +949,11 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
|
"futures",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-base",
|
"iroh-base",
|
||||||
"iroh-tickets",
|
"iroh-tickets",
|
||||||
|
"mainline",
|
||||||
"postcard",
|
"postcard",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -955,6 +987,17 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1793,7 +1836,7 @@ dependencies = [
|
|||||||
"iroh-base",
|
"iroh-base",
|
||||||
"iroh-dns",
|
"iroh-dns",
|
||||||
"iroh-metrics",
|
"iroh-metrics",
|
||||||
"lru",
|
"lru 0.18.1",
|
||||||
"n0-error",
|
"n0-error",
|
||||||
"n0-future",
|
"n0-future",
|
||||||
"noq",
|
"noq",
|
||||||
@@ -1965,6 +2008,12 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1993,6 +2042,12 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.16.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -2014,6 +2069,28 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
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]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3161,6 +3238,16 @@ dependencies = [
|
|||||||
"serde_derive",
|
"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]]
|
[[package]]
|
||||||
name = "serde_bytes"
|
name = "serde_bytes"
|
||||||
version = "0.11.19"
|
version = "0.11.19"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ federation-net = { path = "crates/federation-net" }
|
|||||||
iroh = "1"
|
iroh = "1"
|
||||||
iroh-base = "1"
|
iroh-base = "1"
|
||||||
iroh-tickets = "1"
|
iroh-tickets = "1"
|
||||||
|
mainline = "7"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "io-std", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "io-std", "signal"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
postcard = { version = "1", features = ["alloc"] }
|
postcard = { version = "1", features = ["alloc"] }
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ as an opaque serde-serializable payload.
|
|||||||
|
|
||||||
* Persistent peer identity (`<data_dir>/identity.key`, created on first start).
|
* Persistent peer identity (`<data_dir>/identity.key`, created on first start).
|
||||||
* Connection establishment via a shareable string ticket (`fnet...`).
|
* 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.
|
* An application-level handshake that isolates networks and schemas.
|
||||||
* Typed message exchange in both directions over one QUIC connection.
|
* Typed message exchange in both directions over one QUIC connection.
|
||||||
* Network events (connect, disconnect, message, protocol error).
|
* Network events (connect, disconnect, message, protocol error).
|
||||||
@@ -20,10 +23,9 @@ as an opaque serde-serializable payload.
|
|||||||
|
|
||||||
## What it deliberately does not do (yet)
|
## What it deliberately does not do (yet)
|
||||||
|
|
||||||
No global peer discovery, gossip, broadcast overlay, DHT, content search,
|
No gossip, broadcast overlay, content search, file/chunk/streaming transfer,
|
||||||
file/chunk/streaming transfer, database sync, CRDTs, authorization, ACLs,
|
database sync, CRDTs, authorization, ACLs, HTTP APIs or metrics. The
|
||||||
HTTP APIs or metrics. The architecture allows adding these later as separate
|
architecture allows adding these later as separate modules or ALPN protocols.
|
||||||
modules or ALPN protocols.
|
|
||||||
|
|
||||||
## Usage
|
## 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
|
backwards-incompatible change to your message type requires a new schema
|
||||||
name.
|
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
|
## Tickets
|
||||||
|
|
||||||
A `PeerTicket` is a self-contained invitation string with the `fnet` prefix
|
A `PeerTicket` is a self-contained invitation string with the `fnet` prefix
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use std::sync::mpsc as std_mpsc;
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use artist_dht::{
|
use artist_dht::{
|
||||||
Artist, ArtistDhtConfig, ArtistDhtEvent, ArtistDhtService, NetworkId, PeerTicket, SearchOutcome,
|
Artist, ArtistDhtConfig, ArtistDhtEvent, ArtistDhtService, NetworkId, PeerTicket,
|
||||||
|
RendezvousConfig, SearchOutcome,
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use rustyline::ExternalPrinter;
|
use rustyline::ExternalPrinter;
|
||||||
@@ -30,9 +31,16 @@ struct Args {
|
|||||||
name: String,
|
name: String,
|
||||||
|
|
||||||
/// Ticket(s) of peers to connect to on startup; may be repeated.
|
/// 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)]
|
#[arg(long)]
|
||||||
connect: Vec<String>,
|
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.
|
/// Enable verbose logging.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
@@ -61,11 +69,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let config = ArtistDhtConfig::builder()
|
let mut config_builder = ArtistDhtConfig::builder()
|
||||||
.data_dir(&args.data_dir)
|
.data_dir(&args.data_dir)
|
||||||
.network_id(NetworkId::from_name(&args.network_id))
|
.network_id(NetworkId::from_name(&args.network_id));
|
||||||
.build()
|
if !args.no_bootstrap {
|
||||||
.context("invalid configuration")?;
|
config_builder = config_builder.rendezvous(RendezvousConfig::default());
|
||||||
|
}
|
||||||
|
let config = config_builder.build().context("invalid configuration")?;
|
||||||
|
|
||||||
let (service, mut events) = ArtistDhtService::start(config)
|
let (service, mut events) = ArtistDhtService::start(config)
|
||||||
.await
|
.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!("Waiting for peers... (share the ticket above)");
|
||||||
}
|
}
|
||||||
println!("Type /help for commands.");
|
println!("Type /help for commands.");
|
||||||
|
|||||||
+32
-19
@@ -21,7 +21,10 @@ running peers itself.
|
|||||||
* Each peer has a stable 256-bit `NodeId` derived from its persistent
|
* Each peer has a stable 256-bit `NodeId` derived from its persistent
|
||||||
`federation-net` endpoint id; records live on the `K = 8` XOR-closest
|
`federation-net` endpoint id; records live on the `K = 8` XOR-closest
|
||||||
nodes.
|
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
|
connections to newly learned peers are opened **on demand** from stored
|
||||||
tickets.
|
tickets.
|
||||||
* Deletions propagate as **tombstones** (revision-based, they beat active
|
* 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,
|
Out of scope (by design): fuzzy search, content transfer, CRDTs, consensus,
|
||||||
signatures on DHT records, Sybil protection, accounts, GUI.
|
signatures on DHT records, Sybil protection, accounts, GUI.
|
||||||
|
|
||||||
## Bootstrapping limitation
|
## Bootstrapping
|
||||||
|
|
||||||
There is deliberately **no bootstrap server**, so a peer cannot join a
|
There is deliberately **no bootstrap server**. A peer joins a network knowing
|
||||||
network it has no contact in: you must pass the ticket of *any* already
|
only its `--network-id`: peers of the same network find each other through a
|
||||||
running peer via `--connect`. That first peer has no special role — once
|
shared rendezvous record in the public BitTorrent Mainline DHT (the record's
|
||||||
contacts have spread through peer exchange, it can be switched off.
|
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)
|
## Running the demo (three peers)
|
||||||
|
|
||||||
Start Peer A (alice):
|
Start the peers in any order — they only share the network id:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p artist-dht-cli -- \
|
cargo run -p artist-dht-cli -- \
|
||||||
@@ -51,30 +67,25 @@ cargo run -p artist-dht-cli -- \
|
|||||||
--name alice
|
--name alice
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the printed `Ticket: fnet...`. Start Peer B (bob) with it:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p artist-dht-cli -- \
|
cargo run -p artist-dht-cli -- \
|
||||||
--data-dir ./peer-b \
|
--data-dir ./peer-b \
|
||||||
--network-id demo-artists \
|
--network-id demo-artists \
|
||||||
--name bob \
|
--name bob
|
||||||
--connect 'fnet...'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Start Peer C (charlie), connected **only to A** — it will learn about B via
|
|
||||||
peer exchange:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p artist-dht-cli -- \
|
cargo run -p artist-dht-cli -- \
|
||||||
--data-dir ./peer-c \
|
--data-dir ./peer-c \
|
||||||
--network-id demo-artists \
|
--network-id demo-artists \
|
||||||
--name charlie \
|
--name charlie
|
||||||
--connect 'fnet...'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--connect` may be repeated to dial several peers. All three processes must
|
Within a minute `Peer connected: ...` lines appear on all three. All
|
||||||
use the same `--network-id`; a peer from another network is rejected during
|
processes must use the same `--network-id`; a peer from another network is
|
||||||
the transport handshake.
|
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
|
### Demo scenario
|
||||||
|
|
||||||
@@ -130,6 +141,8 @@ use federation_net::NetworkId;
|
|||||||
let config = ArtistDhtConfig::builder()
|
let config = ArtistDhtConfig::builder()
|
||||||
.data_dir("./peer-a")
|
.data_dir("./peer-a")
|
||||||
.network_id(NetworkId::from_name("demo-artists"))
|
.network_id(NetworkId::from_name("demo-artists"))
|
||||||
|
// Optional: discover peers of this network via the mainline DHT.
|
||||||
|
.rendezvous(artist_dht::RendezvousConfig::default())
|
||||||
.build()?;
|
.build()?;
|
||||||
let (service, mut events) = ArtistDhtService::start(config).await?;
|
let (service, mut events) = ArtistDhtService::start(config).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use federation_net::NetworkId;
|
use federation_net::{NetworkId, RendezvousConfig};
|
||||||
|
|
||||||
use crate::error::{ArtistDhtError, Result};
|
use crate::error::{ArtistDhtError, Result};
|
||||||
|
|
||||||
@@ -40,6 +40,10 @@ pub struct ArtistDhtConfig {
|
|||||||
/// establishing a connection through relays can take much longer than a
|
/// establishing a connection through relays can take much longer than a
|
||||||
/// request over an existing one.
|
/// request over an existing one.
|
||||||
pub transport_timeout: Duration,
|
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 {
|
impl ArtistDhtConfig {
|
||||||
@@ -62,6 +66,7 @@ pub struct ArtistDhtConfigBuilder {
|
|||||||
request_timeout: Option<Duration>,
|
request_timeout: Option<Duration>,
|
||||||
lookup_timeout: Option<Duration>,
|
lookup_timeout: Option<Duration>,
|
||||||
transport_timeout: Option<Duration>,
|
transport_timeout: Option<Duration>,
|
||||||
|
rendezvous: Option<RendezvousConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArtistDhtConfigBuilder {
|
impl ArtistDhtConfigBuilder {
|
||||||
@@ -107,6 +112,12 @@ impl ArtistDhtConfigBuilder {
|
|||||||
self
|
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.
|
/// Validates and builds the configuration.
|
||||||
pub fn build(self) -> Result<ArtistDhtConfig> {
|
pub fn build(self) -> Result<ArtistDhtConfig> {
|
||||||
let data_dir = self
|
let data_dir = self
|
||||||
@@ -131,6 +142,7 @@ impl ArtistDhtConfigBuilder {
|
|||||||
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
|
request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
|
||||||
lookup_timeout: self.lookup_timeout.unwrap_or(DEFAULT_LOOKUP_TIMEOUT),
|
lookup_timeout: self.lookup_timeout.unwrap_or(DEFAULT_LOOKUP_TIMEOUT),
|
||||||
transport_timeout: self.transport_timeout.unwrap_or(DEFAULT_TRANSPORT_TIMEOUT),
|
transport_timeout: self.transport_timeout.unwrap_or(DEFAULT_TRANSPORT_TIMEOUT),
|
||||||
|
rendezvous: self.rendezvous,
|
||||||
};
|
};
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("republish_interval", config.republish_interval),
|
("republish_interval", config.republish_interval),
|
||||||
|
|||||||
@@ -89,4 +89,4 @@ pub use service::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Re-exported types from the transport layer that appear in this API.
|
// 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};
|
||||||
|
|||||||
@@ -106,11 +106,15 @@ impl ArtistDhtService {
|
|||||||
/// Starts the service: opens the database, starts the network engine,
|
/// Starts the service: opens the database, starts the network engine,
|
||||||
/// loads persisted contacts and spawns the maintenance tasks.
|
/// loads persisted contacts and spawns the maintenance tasks.
|
||||||
pub async fn start(config: ArtistDhtConfig) -> Result<(Self, ArtistDhtEventReceiver)> {
|
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)
|
.data_dir(&config.data_dir)
|
||||||
.network_id(config.network_id)
|
.network_id(config.network_id)
|
||||||
.schema_id(SchemaId::from_name(SCHEMA_NAME))
|
.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()
|
.build()
|
||||||
.map_err(|err| ArtistDhtError::Network(err.to_string()))?;
|
.map_err(|err| ArtistDhtError::Network(err.to_string()))?;
|
||||||
let (engine, net_events) = NetworkEngine::start(engine_config).await?;
|
let (engine, net_events) = NetworkEngine::start(engine_config).await?;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ rust-version.workspace = true
|
|||||||
iroh = { workspace = true }
|
iroh = { workspace = true }
|
||||||
iroh-base = { workspace = true }
|
iroh-base = { workspace = true }
|
||||||
iroh-tickets = { workspace = true }
|
iroh-tickets = { workspace = true }
|
||||||
|
mainline = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
postcard = { workspace = true }
|
postcard = { workspace = true }
|
||||||
@@ -21,5 +23,6 @@ rand = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
mainline = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::error::{NetworkError, Result};
|
use crate::error::{NetworkError, Result};
|
||||||
use crate::protocol::{NetworkId, SchemaId};
|
use crate::protocol::{NetworkId, SchemaId};
|
||||||
|
use crate::rendezvous::RendezvousConfig;
|
||||||
|
|
||||||
/// Default maximum size of an encoded domain message (256 KiB).
|
/// Default maximum size of an encoded domain message (256 KiB).
|
||||||
pub const DEFAULT_MAX_MESSAGE_SIZE: usize = 256 * 1024;
|
pub const DEFAULT_MAX_MESSAGE_SIZE: usize = 256 * 1024;
|
||||||
@@ -34,6 +35,9 @@ pub struct NetworkConfig {
|
|||||||
pub event_channel_capacity: usize,
|
pub event_channel_capacity: usize,
|
||||||
/// Maximum number of concurrently processed streams per peer.
|
/// Maximum number of concurrently processed streams per peer.
|
||||||
pub max_concurrent_streams_per_peer: usize,
|
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 {
|
impl NetworkConfig {
|
||||||
@@ -56,6 +60,7 @@ pub struct NetworkConfigBuilder {
|
|||||||
request_timeout: Option<Duration>,
|
request_timeout: Option<Duration>,
|
||||||
event_channel_capacity: Option<usize>,
|
event_channel_capacity: Option<usize>,
|
||||||
max_concurrent_streams_per_peer: Option<usize>,
|
max_concurrent_streams_per_peer: Option<usize>,
|
||||||
|
rendezvous: Option<RendezvousConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NetworkConfigBuilder {
|
impl NetworkConfigBuilder {
|
||||||
@@ -101,6 +106,12 @@ impl NetworkConfigBuilder {
|
|||||||
self
|
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`].
|
/// Validates the configuration and builds a [`NetworkConfig`].
|
||||||
pub fn build(self) -> Result<NetworkConfig> {
|
pub fn build(self) -> Result<NetworkConfig> {
|
||||||
let data_dir = self
|
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 {
|
Ok(NetworkConfig {
|
||||||
data_dir,
|
data_dir,
|
||||||
network_id,
|
network_id,
|
||||||
@@ -164,6 +188,7 @@ impl NetworkConfigBuilder {
|
|||||||
request_timeout,
|
request_timeout,
|
||||||
event_channel_capacity,
|
event_channel_capacity,
|
||||||
max_concurrent_streams_per_peer,
|
max_concurrent_streams_per_peer,
|
||||||
|
rendezvous: self.rendezvous,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,4 +248,28 @@ mod tests {
|
|||||||
.is_err()
|
.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::endpoint::{Connection, RecvStream, SendStream, VarInt, presets};
|
||||||
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
|
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
|
||||||
use iroh::{Endpoint, EndpointId};
|
use iroh::{Endpoint, EndpointAddr, EndpointId};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use tokio::sync::{Semaphore, mpsc};
|
use tokio::sync::{Semaphore, mpsc, watch};
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -24,6 +24,7 @@ use crate::protocol::{
|
|||||||
ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE,
|
ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE,
|
||||||
MessageRejectReason, MessageRequest, MessageResponse, NetworkId, PROTOCOL_VERSION, SchemaId,
|
MessageRejectReason, MessageRequest, MessageResponse, NetworkId, PROTOCOL_VERSION, SchemaId,
|
||||||
};
|
};
|
||||||
|
use crate::rendezvous::{RendezvousClient, RendezvousConfig, now_ms};
|
||||||
use crate::ticket::{PeerTicket, TICKET_VERSION};
|
use crate::ticket::{PeerTicket, TICKET_VERSION};
|
||||||
use crate::wire;
|
use crate::wire;
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ const REJECT_LINGER: Duration = Duration::from_secs(3);
|
|||||||
/// How long [`NetworkEngine::shutdown`] waits for background tasks before
|
/// How long [`NetworkEngine::shutdown`] waits for background tasks before
|
||||||
/// aborting them.
|
/// aborting them.
|
||||||
const SHUTDOWN_TASK_GRACE: Duration = Duration::from_secs(5);
|
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.
|
/// Bounds required of a domain message type.
|
||||||
///
|
///
|
||||||
@@ -85,6 +88,9 @@ struct Shared<M> {
|
|||||||
tasks: Mutex<JoinSet<()>>,
|
tasks: Mutex<JoinSet<()>>,
|
||||||
next_generation: AtomicU64,
|
next_generation: AtomicU64,
|
||||||
shutting_down: AtomicBool,
|
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> {
|
impl<M: Message> Shared<M> {
|
||||||
@@ -396,6 +402,52 @@ impl<M: Message> Shared<M> {
|
|||||||
Ok(())
|
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.
|
/// Runs the client side of the handshake on a fresh outgoing connection.
|
||||||
async fn run_client_handshake(&self, connection: &Connection) -> Result<()> {
|
async fn run_client_handshake(&self, connection: &Connection) -> Result<()> {
|
||||||
let (mut send, mut recv) = connection.open_bi().await.map_err(|err| {
|
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 {
|
fn handshake_code_to_error(code: HandshakeErrorCode) -> NetworkError {
|
||||||
match code {
|
match code {
|
||||||
HandshakeErrorCode::UnsupportedProtocolVersion => NetworkError::UnsupportedProtocolVersion,
|
HandshakeErrorCode::UnsupportedProtocolVersion => NetworkError::UnsupportedProtocolVersion,
|
||||||
@@ -506,12 +617,26 @@ impl<M: Message> NetworkEngine<M> {
|
|||||||
tasks: Mutex::new(JoinSet::new()),
|
tasks: Mutex::new(JoinSet::new()),
|
||||||
next_generation: AtomicU64::new(0),
|
next_generation: AtomicU64::new(0),
|
||||||
shutting_down: AtomicBool::new(false),
|
shutting_down: AtomicBool::new(false),
|
||||||
|
shutdown_signal: watch::Sender::new(false),
|
||||||
});
|
});
|
||||||
let handler = FederationProtocol {
|
let handler = FederationProtocol {
|
||||||
shared: Arc::downgrade(&shared),
|
shared: Arc::downgrade(&shared),
|
||||||
};
|
};
|
||||||
let router = Router::builder(endpoint).accept(ALPN, handler).spawn();
|
let router = Router::builder(endpoint).accept(ALPN, handler).spawn();
|
||||||
*lock(&shared.router) = Some(router);
|
*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!(
|
info!(
|
||||||
endpoint_id = %endpoint_id,
|
endpoint_id = %endpoint_id,
|
||||||
network_id = %shared.config.network_id,
|
network_id = %shared.config.network_id,
|
||||||
@@ -587,54 +712,12 @@ impl<M: Message> NetworkEngine<M> {
|
|||||||
if ticket.schema_id != shared.config.schema_id {
|
if ticket.schema_id != shared.config.schema_id {
|
||||||
return Err(NetworkError::SchemaMismatch);
|
return Err(NetworkError::SchemaMismatch);
|
||||||
}
|
}
|
||||||
let target = ticket.endpoint_id();
|
if ticket.endpoint_id() == self.endpoint_id() {
|
||||||
if target == self.endpoint_id() {
|
|
||||||
return Err(NetworkError::InvalidTicket(
|
return Err(NetworkError::InvalidTicket(
|
||||||
"the ticket points to this peer itself".to_string(),
|
"the ticket points to this peer itself".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if lock(&shared.peers).contains_key(&target) {
|
shared.connect_to_addr(ticket.endpoint_addr).await
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a domain message to a connected peer.
|
/// Sends a domain message to a connected peer.
|
||||||
@@ -719,6 +802,7 @@ impl<M: Message> NetworkEngine<M> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
info!("network engine shutting down");
|
info!("network engine shutting down");
|
||||||
|
let _ = shared.shutdown_signal.send(true);
|
||||||
|
|
||||||
// Close all active connections.
|
// Close all active connections.
|
||||||
let states: Vec<PeerState> = lock(&shared.peers)
|
let states: Vec<PeerState> = lock(&shared.peers)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
//! * [`NetworkId`] — isolates independent P2P networks from each other.
|
//! * [`NetworkId`] — isolates independent P2P networks from each other.
|
||||||
//! * [`SchemaId`] — isolates applications with incompatible message schemas.
|
//! * [`SchemaId`] — isolates applications with incompatible message schemas.
|
||||||
//! * [`PeerTicket`] — a shareable string invitation used to reach a peer.
|
//! * [`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
|
//! * [`NetworkEngine`] — the engine itself; [`NetworkEventReceiver`] delivers
|
||||||
//! [`NetworkEvent`]s to the application.
|
//! [`NetworkEvent`]s to the application.
|
||||||
//!
|
//!
|
||||||
@@ -54,6 +57,7 @@ mod error;
|
|||||||
mod event;
|
mod event;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
|
mod rendezvous;
|
||||||
mod ticket;
|
mod ticket;
|
||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
@@ -65,6 +69,7 @@ pub use engine::{Message, NetworkEngine};
|
|||||||
pub use error::{NetworkError, Result};
|
pub use error::{NetworkError, Result};
|
||||||
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
|
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
|
||||||
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
|
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};
|
pub use ticket::{PeerTicket, TICKET_VERSION};
|
||||||
|
|
||||||
// Re-exported Iroh types that appear in the public API.
|
// 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::{
|
use federation_net::{
|
||||||
ConnectionDirection, EndpointId, NetworkConfig, NetworkEngine, NetworkError, NetworkEvent,
|
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.
|
/// Hard cap on every test so a regression can never hang CI.
|
||||||
@@ -297,3 +297,65 @@ async fn disconnect_removes_peer() {
|
|||||||
.await
|
.await
|
||||||
.expect("test timed out");
|
.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