Bound iroh path retries to prevent multi-connection OOM

A long-running rc.33 daemon reached OOM with roughly 22 GiB resident and
24 GiB swapped. Live inspection found 40 GiB and 10/20 GiB anonymous
container mappings; the active worker stack and tracing metadata identified
iroh's scheduled path-open retry loop. In iroh 1.2.0 every failed connection
requeues the same path, while each retry visits every connection again.
Separate control and data connections multiply the queue geometrically.

Vendor the published iroh 1.2.0 source and select it with a workspace Cargo
patch. Deduplicate retry admission and cap distinct pending paths at 64,
evicting the oldest before inserting a new candidate. Keep the current and
next retry batches bounded without combining control/data connections or
changing wire protocols, identity derivation, authentication or state.
Document source provenance, the live evidence and removal conditions.
Upstream PR 4551 merged on October 6, but inspection of the official 1.3.0
crate still shows the unconditional enqueue, so that upgrade alone is not
a fix. No version bump, release tag or installed-service change.

The default workspace suite includes the exact vendored admission module.
Three regressions failed with the original enqueue and pass with the fix:
repeated failures across connections, duplicate admission at capacity, and
10,000 distinct candidates with bounded length/capacity and FIFO eviction.
Transport integration tests continue using real endpoints and encryption.

Checks on Linux in nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with only the inherited
  TSUNAGI_CONTROL_SOCKET removed from the test environment
- git diff --cached --check

Initial full-suite runs failed six DNS readiness checks because the inherited
system socket path overrode their temporary state directories. Clearing that
environment variable resolves those failures without changing application code.
Root/network-dependent ignored tests and other operating systems were not run.
This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-14 23:28:53 +01:00
1 parent 4e235cbac6
commit 2b0c938487
86 files changed
+35810 -3

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
//! Degradation ladder: find where holepunching breaks under worsening conditions
use std::time::Duration;
use iroh::endpoint::Side;
use n0_error::{Result, StackResultExt, StdResultExt};
use n0_tracing_test::traced_test;
use patchbay::{LinkCondition, LinkDirection, Nat};
use testdir::testdir;
use tracing::info;
use super::util::{Pair, PathConnectionExt, lab_with_relay, ping_accept, ping_open};
/// A ladder of increasingly degraded real-world links, used to find where
/// hole-punching breaks.
///
/// Each rung models a plausible last-mile scenario rather than a synthetic
/// ramp. Loss is bursty (Gilbert-Elliott), the way real radio links drop
/// packets in fades and handovers, rather than independent per-packet loss, and
/// the mean burst grows as conditions worsen. Every rung is bandwidth-capped
/// with a buffer sized to its round-trip time (via [`rtt_ms`]), so queueing
/// delay and congestion loss appear as they do on a real bottleneck.
///
/// The first rungs walk through common degraded links, from good wifi to a
/// congested cellular connection, with loss rates that track measured medians:
/// good LTE and wifi sit under 1 %, cell-edge and congested links reach a few
/// percent. The last three cover distinct extremes: a geostationary satellite
/// (defined by its ~600 ms round trip), a barely-usable link at the edge of
/// coverage, and an absurd stress case past what any real link sustains, there
/// to find the point where the connection gives up entirely. Reordering, which
/// real links rarely exhibit above a fraction of a percent, is dropped.
///
/// [`rtt_ms`]: patchbay::LinkCondition::rtt_ms
const DEGRADE_LEVELS: &[LinkCondition] = &[
// 0: good home wifi, 5 GHz, close to the access point.
LinkCondition::new()
.rate_mbit(100)
.rtt_ms(16)
.bursty_loss(0.1)
.label("good-wifi"),
// 1: congested 2.4 GHz wifi, interference and distance.
LinkCondition::new()
.rate_mbit(25)
.rtt_ms(40)
.jitter_ms(12)
.loss_pct(1.0)
.loss_burst_pkts(6)
.label("congested-wifi"),
// 2: LTE at the cell edge, weak signal.
LinkCondition::new()
.rate_mbit(8)
.rtt_ms(90)
.jitter_ms(15)
.loss_pct(2.0)
.loss_burst_pkts(8)
.label("weak-4g"),
// 3: degraded 3G, deep buffers (bufferbloat).
LinkCondition::new()
.rate_mbit(3)
.rtt_ms(200)
.jitter_ms(30)
.loss_pct(3.0)
.loss_burst_pkts(10)
.label("slow-3g"),
// 4: oversubscribed cellular under load, heavy bufferbloat.
LinkCondition::new()
.rate_kbit(1500)
.rtt_ms(300)
.jitter_ms(40)
.loss_pct(5.0)
.loss_burst_pkts(12)
.label("congested-cellular"),
// 5: a very bad cellular link, such as a moving vehicle in poor coverage:
// low bandwidth, high latency, and heavy bursty loss.
LinkCondition::new()
.rate_kbit(800)
.rtt_ms(450)
.jitter_ms(60)
.loss_pct(8.0)
.loss_burst_pkts(20)
.label("very-bad-cellular"),
// 6: geostationary satellite (Viasat / HughesNet class). The ~600 ms round
// trip is the defining impairment; loss stays modest.
LinkCondition::new()
.rate_mbit(25)
.rtt_ms(600)
.jitter_ms(40)
.loss_pct(1.5)
.loss_burst_pkts(6)
.label("satellite"),
// 7: barely-usable cellular or wifi at the edge of coverage. Low bandwidth,
// heavy bufferbloat, and long loss bursts.
LinkCondition::new()
.rate_kbit(500)
.rtt_ms(450)
.jitter_ms(80)
.loss_pct(12.0)
.loss_burst_pkts(25)
.label("barely-usable"),
// 8: absurd stress case, past what any real link sustains, to find where the
// connection gives up entirely.
LinkCondition::new()
.rate_kbit(256)
.rtt_ms(800)
.jitter_ms(150)
.loss_pct(25.0)
.loss_burst_pkts(40)
.label("absurd"),
];
/// Runs a single degradation level.
///
/// Creates two devices behind Home NATs, applies the given [`LinkCondition`] to
/// `impaired_side`, then attempts to holepunch and ping. Returns the
/// [`TestGuard`] on success so the caller can mark it as passed.
async fn run_degrade_level(impaired_side: Side, level: usize) -> Result<()> {
let (lab, relay_map, _relay_guard, guard) = lab_with_relay(testdir!()).await?;
let nat1 = lab.add_router("nat1").nat(Nat::Moderate).build().await?;
let nat2 = lab.add_router("nat2").nat(Nat::Moderate).build().await?;
let timeout = Duration::from_secs(20 + level as u64 * 10);
let limits = DEGRADE_LEVELS[level];
let server = lab
.add_device("server")
.iface("eth0", nat1.id())
.build()
.await?;
let client = lab
.add_device("client")
.iface("eth0", nat2.id())
.build()
.await?;
let impaired_device = match impaired_side {
Side::Client => &client,
Side::Server => &server,
};
impaired_device
.iface("eth0")
.unwrap()
.set_condition(limits, LinkDirection::Both)
.await?;
info!(?impaired_side, ?limits, %level, ?timeout, "degrade test start");
let result = tokio::time::timeout(
timeout,
Pair::new(relay_map)
.server(server, async move |_dev, _ep, conn| {
ping_accept(&conn, timeout).await.context("ping_accept")?;
conn.closed().await;
Ok(())
})
.client(client, async move |_dev, _ep, conn| {
info!("waiting for connection to become direct");
conn.wait_ip(timeout).await.context("holepunch to direct")?;
info!("direct path established, sending ping");
ping_open(&conn, timeout).await.context("ping_open")?;
info!("ping complete");
conn.close(0u32.into(), b"bye");
Ok(())
})
.run(),
)
.await
.std_context("pair timed out")
.flatten();
match &result {
Ok(()) => tracing::event!(
target: "test::_events::ladder_pass",
tracing::Level::INFO,
level,
latency_ms = limits.latency_ms,
loss_pct = limits.loss_pct,
loss_burst_pkts = ?limits.loss_burst_pkts,
impaired_side = ?impaired_side,
"PASSED",
),
Err(err) => tracing::event!(
target: "test::_events::ladder_fail",
tracing::Level::WARN,
level,
latency_ms = limits.latency_ms,
loss_pct = limits.loss_pct,
loss_burst_pkts = ?limits.loss_burst_pkts,
impaired_side = ?impaired_side,
error = format!("{err:#}"),
"FAILED",
),
}
result?;
guard.ok();
Ok(())
}
#[tokio::test]
#[traced_test]
async fn degrade_server_good_wifi() -> Result {
run_degrade_level(Side::Server, 0).await
}
#[tokio::test]
#[traced_test]
async fn degrade_server_congested_wifi() -> Result {
run_degrade_level(Side::Server, 1).await
}
#[tokio::test]
#[traced_test]
async fn degrade_server_weak_4g() -> Result {
run_degrade_level(Side::Server, 2).await
}
#[tokio::test]
#[traced_test]
async fn degrade_server_slow_3g() -> Result {
run_degrade_level(Side::Server, 3).await
}
// Still too flaky.
// #[tokio::test]
// #[traced_test]
// async fn degrade_server_congested_cellular() -> Result {
// run_degrade_level(Side::Server, 4).await
// }
#[tokio::test]
#[traced_test]
async fn degrade_server_very_bad_cellular() -> Result {
run_degrade_level(Side::Server, 5).await
}
#[tokio::test]
#[traced_test]
async fn degrade_server_satellite() -> Result {
run_degrade_level(Side::Server, 6).await
}
#[tokio::test]
#[traced_test]
#[ignore = "not yet passing"]
async fn degrade_server_barely_usable() -> Result {
run_degrade_level(Side::Server, 7).await
}
#[tokio::test]
#[traced_test]
#[ignore = "not yet passing"]
async fn degrade_server_absurd() -> Result {
run_degrade_level(Side::Server, 8).await
}
#[tokio::test]
#[traced_test]
async fn degrade_client_good_wifi() -> Result {
run_degrade_level(Side::Client, 0).await
}
#[tokio::test]
#[traced_test]
async fn degrade_client_congested_wifi() -> Result {
run_degrade_level(Side::Client, 1).await
}
#[tokio::test]
#[traced_test]
async fn degrade_client_weak_4g() -> Result {
run_degrade_level(Side::Client, 2).await
}
#[tokio::test]
#[traced_test]
async fn degrade_client_slow_3g() -> Result {
run_degrade_level(Side::Client, 3).await
}
#[tokio::test]
#[traced_test]
async fn degrade_client_congested_cellular() -> Result {
run_degrade_level(Side::Client, 4).await
}
#[tokio::test]
#[traced_test]
async fn degrade_client_very_bad_cellular() -> Result {
run_degrade_level(Side::Client, 5).await
}
#[tokio::test]
#[traced_test]
async fn degrade_client_satellite() -> Result {
run_degrade_level(Side::Client, 6).await
}
#[tokio::test]
#[traced_test]
#[ignore = "not yet passing"]
async fn degrade_client_barely_usable() -> Result {
run_degrade_level(Side::Client, 7).await
}
#[tokio::test]
#[traced_test]
#[ignore = "not yet passing"]
async fn degrade_client_absurd() -> Result {
run_degrade_level(Side::Client, 8).await
}
+174
View File
@@ -0,0 +1,174 @@
//! NAT traversal matrix tests.
//!
//! Tests holepunching across combinations of the upstream [`patchbay::Nat`]
//! behavior tiers:
//!
//! - `None`: no NAT, publicly routable.
//! - `Open`: endpoint-independent mapping (EIM), endpoint-independent
//! filtering (EIF); RFC 3489 full cone. Typical of routers with UPnP or
//! static port forwarding.
//! - `Moderate`: EIM, address-and-port-dependent filtering (APDF); RFC 3489
//! port-restricted cone. The typical home router.
//! - `Strict`: endpoint-dependent mapping (EDM) with random ports, APDF; RFC
//! 3489 symmetric. Holepunching between two `Strict` NATs requires a relay.
//!
//! Every test expects a direct path to be established. Tests where holepunching
//! is not yet working are marked `#[ignore]`.
use std::time::Duration;
use n0_error::{Result, StackResultExt};
use n0_tracing_test::traced_test;
use patchbay::Nat;
use testdir::testdir;
use tracing::info;
use super::util::{Pair, PathConnectionExt, lab_with_relay};
use crate::util::{is_relayed, ping_accept, ping_open};
async fn run_nat_holepunch(nat_server: Nat, nat_client: Nat) -> Result {
let (lab, relay_map, _relay_guard, guard) = lab_with_relay(testdir!()).await?;
let router_server = lab.add_router("nat_server").nat(nat_server).build().await?;
let router_client = lab.add_router("nat_client").nat(nat_client).build().await?;
let server = lab
.add_device("server")
.uplink(router_server.id())
.build()
.await?;
let client = lab
.add_device("client")
.uplink(router_client.id())
.build()
.await?;
let timeout = Duration::from_secs(15);
Pair::new(relay_map)
.server(server, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
conn.wait_ip(timeout).await.context("holepunch to direct")?;
info!("connection became direct");
ping_accept(&conn, timeout).await?;
conn.closed().await;
Ok(())
})
.client(client, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
conn.wait_ip(timeout).await.context("holepunch to direct")?;
info!("connection became direct");
ping_open(&conn, timeout).await?;
conn.close(0u32.into(), b"bye");
Ok(())
})
.run()
.await?;
guard.ok();
Ok(())
}
// None x *
#[tokio::test]
#[traced_test]
async fn nat_none_x_none() -> Result {
run_nat_holepunch(Nat::None, Nat::None).await
}
#[tokio::test]
#[traced_test]
async fn nat_none_x_open() -> Result {
run_nat_holepunch(Nat::None, Nat::Open).await
}
#[tokio::test]
#[traced_test]
async fn nat_none_x_moderate() -> Result {
run_nat_holepunch(Nat::None, Nat::Moderate).await
}
#[tokio::test]
#[traced_test]
async fn nat_none_x_strict() -> Result {
run_nat_holepunch(Nat::None, Nat::Strict).await
}
// Open x *
#[tokio::test]
#[traced_test]
async fn nat_open_x_none() -> Result {
run_nat_holepunch(Nat::Open, Nat::None).await
}
#[tokio::test]
#[traced_test]
async fn nat_open_x_open() -> Result {
run_nat_holepunch(Nat::Open, Nat::Open).await
}
#[tokio::test]
#[traced_test]
async fn nat_open_x_moderate() -> Result {
run_nat_holepunch(Nat::Open, Nat::Moderate).await
}
#[tokio::test]
#[traced_test]
async fn nat_open_x_strict() -> Result {
run_nat_holepunch(Nat::Open, Nat::Strict).await
}
// Moderate x *
#[tokio::test]
#[traced_test]
async fn nat_moderate_x_none() -> Result {
run_nat_holepunch(Nat::Moderate, Nat::None).await
}
#[tokio::test]
#[traced_test]
async fn nat_moderate_x_open() -> Result {
run_nat_holepunch(Nat::Moderate, Nat::Open).await
}
#[tokio::test]
#[traced_test]
async fn nat_moderate_x_moderate() -> Result {
run_nat_holepunch(Nat::Moderate, Nat::Moderate).await
}
#[tokio::test]
#[traced_test]
#[ignore = "not yet passing (and likely can't without port guessing)"]
async fn nat_moderate_x_strict() -> Result {
run_nat_holepunch(Nat::Moderate, Nat::Strict).await
}
// Strict x *
#[tokio::test]
#[traced_test]
async fn nat_strict_x_none() -> Result {
run_nat_holepunch(Nat::Strict, Nat::None).await
}
#[tokio::test]
#[traced_test]
async fn nat_strict_x_open() -> Result {
run_nat_holepunch(Nat::Strict, Nat::Open).await
}
#[tokio::test]
#[traced_test]
#[ignore = "not yet passing (and likely can't without port guessing)"]
async fn nat_strict_x_moderate() -> Result {
run_nat_holepunch(Nat::Strict, Nat::Moderate).await
}
#[tokio::test]
#[traced_test]
#[ignore = "not yet passing (and likely can't without port guessing)"]
async fn nat_strict_x_strict() -> Result {
run_nat_holepunch(Nat::Strict, Nat::Strict).await
}
+348
View File
@@ -0,0 +1,348 @@
//! Relay connectivity and reconnect tests.
//!
//! The relay from [`util::relay::run_relay_server`] binds `[::]` and is
//! reachable as `https://relay.test` through lab-wide DNS A and AAAA records.
//!
//! The connect tests place both peers on access networks that support only
//! one IP family, so all traffic to the relay uses that family. The
//! reconnect tests pin the connection to the relay by putting both peers
//! behind symmetric (`Nat::Strict`) NATs, for which holepunching fails
//! even though IP transports stay enabled, and then break either one
//! peer's access link or the relay server itself.
use std::time::Duration;
use iroh::endpoint::Side;
use n0_error::{Result, StackResultExt, StdResultExt, anyerr};
use n0_future::task::AbortOnDropHandle;
use n0_tracing_test::traced_test;
use patchbay::{FirewallConfigBuilder, IpSupport, Lab, Nat, OutDir};
use testdir::testdir;
use tokio::sync::oneshot;
use tracing::info;
use super::util::{self, Pair, is_relayed, lab_with_relay, ping_accept, ping_open};
/// Firewall rules that block all outbound UDP except DNS, leaving TCP open.
///
/// The lab DNS server is UDP-only, so port 53 must stay open. Everything else
/// over UDP (QAD, holepunch probes) is dropped, forcing traffic onto the relay
/// over TCP. Pass to [`RouterBuilder::firewall_custom`](patchbay::RouterBuilder).
fn block_udp_except_dns(f: &mut FirewallConfigBuilder) -> &mut FirewallConfigBuilder {
f.block_inbound().allow_udp(&[53])
}
/// Connects two peers through the relay over a single IP family.
///
/// The relay's own network is dual-stack; both access routers support only
/// the family under test. Their firewall drops all UDP except DNS, so QAD
/// and holepunching are impossible and the relay is the only usable path:
/// the ping exchange proves the relay carries data over that family.
/// Direct-path coverage per family lives in the switch_uplink and nat
/// matrices.
async fn run_relay_connect(ip_support: IpSupport) -> Result {
let (lab, relay_map, _relay_guard, guard) = lab_with_relay(testdir!()).await?;
let access1 = lab
.add_router("access1")
.ip_support(ip_support)
.firewall_custom(block_udp_except_dns)
.build()
.await?;
let access2 = lab
.add_router("access2")
.ip_support(ip_support)
.firewall_custom(block_udp_except_dns)
.build()
.await?;
let server = lab
.add_device("server")
.uplink(access1.id())
.build()
.await?;
let client = lab
.add_device("client")
.uplink(access2.id())
.build()
.await?;
// Sanity-check the harness: only the family under test is assigned.
for dev in [&server, &client] {
assert_eq!(dev.ip().is_some(), ip_support.has_v4(), "IPv4 assignment");
assert_eq!(dev.ip6().is_some(), ip_support.has_v6(), "IPv6 assignment");
}
let timeout = Duration::from_secs(10);
Pair::new(relay_map)
.server(server, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_accept(&conn, timeout).await.context("ping_accept")?;
assert!(is_relayed(&conn), "still relayed with UDP blocked");
conn.closed().await;
Ok(())
})
.client(client, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_open(&conn, timeout).await.context("ping_open")?;
assert!(is_relayed(&conn), "still relayed with UDP blocked");
conn.close(0u32.into(), b"bye");
Ok(())
})
.run()
.await?;
guard.ok();
Ok(())
}
#[tokio::test]
#[traced_test]
async fn relay_connect_ipv4() -> Result {
run_relay_connect(IpSupport::V4Only).await
}
#[tokio::test]
#[traced_test]
async fn relay_connect_ipv6() -> Result {
run_relay_connect(IpSupport::V6Only).await
}
/// Which peers sit behind a UDP-blocking access router.
#[derive(Debug, Clone, Copy)]
enum UdpBlocked {
Both,
ServerOnly,
ClientOnly,
}
/// Connects two peers of which one or both cannot use UDP beyond DNS.
///
/// The blocked peers sit behind a home NAT that drops unsolicited inbound and
/// all outbound UDP except DNS (the lab DNS server is UDP-only), the hotel or
/// airport guest WiFi shape. That rules out QAD and holepunching, so they must
/// reach each other through the relay over TCP. Any unblocked peer is behind a
/// plain home NAT; a direct path needs UDP on both ends, so the connection
/// stays relayed in every variant.
async fn run_relay_udp_blocked(blocked: UdpBlocked) -> Result {
let (lab, relay_map, _relay_guard, guard) = lab_with_relay(testdir!()).await?;
let server_blocked = matches!(blocked, UdpBlocked::Both | UdpBlocked::ServerOnly);
let client_blocked = matches!(blocked, UdpBlocked::Both | UdpBlocked::ClientOnly);
let mut net1 = lab.add_router("net1").nat(Nat::Moderate);
if server_blocked {
net1 = net1.firewall_custom(block_udp_except_dns);
}
let net1 = net1.build().await?;
let mut net2 = lab.add_router("net2").nat(Nat::Moderate);
if client_blocked {
net2 = net2.firewall_custom(block_udp_except_dns);
}
let net2 = net2.build().await?;
let server = lab.add_device("server").uplink(net1.id()).build().await?;
let client = lab.add_device("client").uplink(net2.id()).build().await?;
// Long enough for a holepunch to complete if one were possible.
let hold = Duration::from_secs(3);
let timeout = Duration::from_secs(15);
Pair::new(relay_map)
.server(server, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_accept(&conn, timeout).await.context("ping 1")?;
tokio::time::sleep(hold).await;
assert!(is_relayed(&conn), "still relayed with UDP blocked");
ping_accept(&conn, timeout).await.context("ping 2")?;
conn.closed().await;
Ok(())
})
.client(client, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_open(&conn, timeout).await.context("ping 1")?;
tokio::time::sleep(hold).await;
assert!(is_relayed(&conn), "still relayed with UDP blocked");
ping_open(&conn, timeout).await.context("ping 2")?;
conn.close(0u32.into(), b"bye");
Ok(())
})
.run()
.await?;
guard.ok();
Ok(())
}
#[tokio::test]
#[traced_test]
async fn relay_udp_blocked_both() -> Result {
run_relay_udp_blocked(UdpBlocked::Both).await
}
#[tokio::test]
#[traced_test]
async fn relay_udp_blocked_server() -> Result {
run_relay_udp_blocked(UdpBlocked::ServerOnly).await
}
#[tokio::test]
#[traced_test]
async fn relay_udp_blocked_client() -> Result {
run_relay_udp_blocked(UdpBlocked::ClientOnly).await
}
/// Takes one peer's link down and verifies the relay path recovers.
///
/// Both peers sit behind symmetric NATs, so the connection cannot escape to
/// a direct path; the affected peer's relay client notices the dead link and
/// reconnects once the link is back.
async fn run_relay_reconnect_link_outage(outage_side: Side, downtime: Duration) -> Result {
let (lab, relay_map, _relay_guard, guard) = lab_with_relay(testdir!()).await?;
let nat1 = lab.add_router("nat1").nat(Nat::Strict).build().await?;
let nat2 = lab.add_router("nat2").nat(Nat::Strict).build().await?;
let outage = lab.add_device("outage").uplink(nat1.id()).build().await?;
let peer = lab.add_device("peer").uplink(nat2.id()).build().await?;
let timeout = Duration::from_secs(15);
// The reconnect backoff can sleep through the link returning: dials during
// the outage fail fast and grow the backoff to several seconds plus jitter,
// so the post-outage pings need generous headroom.
let recovery_timeout = Duration::from_secs(30);
Pair::new(relay_map)
.left(outage_side, outage, async move |dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_open(&conn, timeout)
.await
.context("ping before outage")?;
info!("killing link for {downtime:?}");
dev.iface("eth0").unwrap().link_down().await?;
tokio::time::sleep(downtime).await;
dev.iface("eth0").unwrap().link_up().await?;
info!("link restored, waiting for relay reconnect");
ping_open(&conn, recovery_timeout)
.await
.context("ping after link restored")?;
assert!(is_relayed(&conn), "still relayed behind symmetric NAT");
conn.close(0u32.into(), b"bye");
Ok(())
})
.right(peer, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_accept(&conn, timeout)
.await
.context("ping before outage")?;
ping_accept(&conn, recovery_timeout)
.await
.context("ping after link restored")?;
conn.closed().await;
Ok(())
})
.run()
.await?;
guard.ok();
Ok(())
}
#[tokio::test]
#[traced_test]
async fn relay_reconnect_link_outage_client() -> Result {
run_relay_reconnect_link_outage(Side::Client, Duration::from_secs(5)).await
}
#[tokio::test]
#[traced_test]
async fn relay_reconnect_link_outage_server() -> Result {
run_relay_reconnect_link_outage(Side::Server, Duration::from_secs(5)).await
}
/// Restarts the relay server underneath an established relay-pinned
/// connection and verifies data flows again once the clients reconnect.
///
/// The replacement relay binds the same ports and is reachable under the
/// same `https://relay.test` URL, like a relay deployment restarting in
/// place. Both peers' relay actors must notice the dead connection and
/// reconnect on their own.
#[tokio::test]
#[traced_test]
async fn relay_reconnect_relay_restart() -> Result {
let mut builder = Lab::builder().outdir(OutDir::Exact(testdir!()));
if let Some(name) = std::thread::current().name() {
builder = builder.label(name);
}
let lab = builder.build().await?;
let guard = lab.test_guard();
let dc = lab
.add_router("dc")
.ip_support(IpSupport::DualStack)
.build()
.await?;
let dev_relay = lab.add_device("relay").uplink(dc.id()).build().await?;
// Register DNS before the endpoint devices below; only later devices
// resolve it.
let dns = lab.dns_server()?;
dns.set_host("relay.test", dev_relay.ip().expect("relay has IPv4").into())?;
dns.set_host(
"relay.test",
dev_relay.ip6().expect("relay has IPv6").into(),
)?;
let (map_tx, map_rx) = oneshot::channel();
let (restart_tx, restart_rx) = oneshot::channel::<()>();
let (restarted_tx, restarted_rx) = oneshot::channel::<()>();
let relay_task = dev_relay.spawn(async move |_dev| {
let (relay_map, server) = util::relay::run_relay_server().await.expect("relay spawn");
map_tx.send(relay_map).expect("test task alive");
restart_rx.await.expect("restart signal");
server.shutdown().await.expect("relay shutdown");
info!("relay stopped, starting a new relay on the same ports");
// The old server's sockets are released asynchronously after
// shutdown() returns; retry briefly if a port is still taken.
let mut attempts = 0;
let (_relay_map, _server) = loop {
match util::relay::run_relay_server().await {
Ok(relay) => break relay,
Err(err) if attempts < 20 => {
attempts += 1;
info!("relay respawn attempt {attempts} failed: {err:#}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(err) => panic!("relay respawn: {err:#}"),
}
};
restarted_tx.send(()).expect("test task alive");
std::future::pending::<()>().await;
})?;
let _relay_guard = AbortOnDropHandle::new(relay_task);
let relay_map = map_rx.await.std_context("relay task died before binding")?;
let nat1 = lab.add_router("nat1").nat(Nat::Strict).build().await?;
let nat2 = lab.add_router("nat2").nat(Nat::Strict).build().await?;
let server = lab.add_device("server").uplink(nat1.id()).build().await?;
let client = lab.add_device("client").uplink(nat2.id()).build().await?;
let timeout = Duration::from_secs(20);
Pair::new(relay_map)
.server(server, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_accept(&conn, timeout)
.await
.context("ping before restart")?;
ping_accept(&conn, timeout)
.await
.context("ping after restart")?;
conn.closed().await;
Ok(())
})
.client(client, async move |_dev, _ep, conn| {
assert!(is_relayed(&conn), "connection started relayed");
ping_open(&conn, timeout)
.await
.context("ping before restart")?;
restart_tx
.send(())
.map_err(|_| anyerr!("relay task died"))?;
restarted_rx.await.std_context("relay did not restart")?;
info!("relay restarted, waiting for reconnect");
ping_open(&conn, timeout)
.await
.context("ping after relay restart")?;
assert!(is_relayed(&conn), "still relayed behind symmetric NAT");
conn.close(0u32.into(), b"bye");
Ok(())
})
.run()
.await?;
guard.ok();
Ok(())
}
+239
View File
@@ -0,0 +1,239 @@
//! Uplink switch tests.
//!
//! Each test verifies that an iroh connection survives a network change on one
//! side: the switching device replugs from one router to another, and we verify
//! that a new direct path is established and data flows over it.
//!
//! We test every combination of:
//! - which side switches (client or server)
//! - which IP families are involved (v4, v6, dual-stack)
//!
//! The non-switching side is always behind a dual-stack Home NAT, so it is
//! reachable on both address families regardless of what the switcher does.
use std::time::Duration;
use iroh::{TransportAddr, endpoint::Side};
use n0_error::{Result, StackResultExt};
use n0_tracing_test::traced_test;
use patchbay::{IpSupport, RouterPreset};
use testdir::testdir;
use tracing::info;
use crate::util::{Pair, PathConnectionExt, lab_with_relay, ping_accept, ping_open};
/// Builds the lab topology and runs a single uplink switch test.
///
/// The topology has three routers:
/// - "observer": dual-stack Home NAT for the non-switching side
/// - "from": the switching side's initial router (determined by `from`)
/// - "to": the router the switching side replugs to (determined by `to`)
///
/// After both sides holepunch and exchange a ping, the switching side replugs
/// from "from" to "to". The observer waits for the selected path to change,
/// then both sides exchange another ping to confirm the new path works.
async fn run_switch_uplink(switching_side: Side, from: IpSupport, to: IpSupport) -> Result {
let (lab, relay_map, _relay_guard, guard) = lab_with_relay(testdir!()).await?;
let timeout = Duration::from_secs(30);
let observer_id = lab
.add_router("observer")
.preset(RouterPreset::Home)
.ip_support(IpSupport::DualStack)
.build()
.await?
.id();
let from_id = lab
.add_router("from")
.preset(router_preset(from))
.ip_support(from)
.build()
.await?
.id();
let to_id = lab
.add_router("to")
.preset(router_preset(to))
.ip_support(to)
.build()
.await?
.id();
let switcher = lab.add_device("switcher").uplink(from_id).build().await?;
let observer = lab
.add_device("observer")
.uplink(observer_id)
.build()
.await?;
info!(?switching_side, ?from, ?to, "switch uplink test start");
Pair::new(relay_map)
.left(switching_side, switcher, async move |dev, _ep, conn| {
conn.wait_ip(timeout).await.context("initial holepunch")?;
ping_accept(&conn, timeout)
.await
.context("ping_accept before switch")?;
dev.iface("eth0").unwrap().replug(to_id).await?;
ping_accept(&conn, timeout)
.await
.context("ping_accept after switch")?;
conn.closed().await;
Ok(())
})
.right(observer, async move |_dev, _ep, conn| {
conn.wait_ip(timeout).await.context("initial holepunch")?;
let previous: Vec<TransportAddr> = conn
.paths()
.iter()
.map(|p| p.remote_addr().clone())
.collect();
ping_open(&conn, timeout)
.await
.context("ping_open before switch")?;
conn.wait_selected(timeout, |p| path_switched(to, &previous, p.remote_addr()))
.await
.context("path did not switch")?;
ping_open(&conn, timeout)
.await
.context("ping_open after switch")?;
conn.close(0u32.into(), b"bye");
Ok(())
})
.run()
.await?;
guard.ok();
Ok(())
}
fn router_preset(ip: IpSupport) -> RouterPreset {
match ip {
IpSupport::V4Only => RouterPreset::Home,
IpSupport::V6Only => RouterPreset::IspV6,
IpSupport::DualStack => RouterPreset::Home,
}
}
fn path_switched(to: IpSupport, previous: &[TransportAddr], new: &TransportAddr) -> bool {
if previous.contains(new) {
return false;
}
match to {
IpSupport::V4Only => matches!(new, TransportAddr::Ip(a) if a.ip().is_ipv4()),
IpSupport::V6Only => matches!(new, TransportAddr::Ip(a) if a.ip().is_ipv6()),
IpSupport::DualStack => matches!(new, TransportAddr::Ip(_)),
}
}
// --- Client switches uplink ---
#[tokio::test]
#[traced_test]
async fn switch_client_v4_to_v4() -> Result {
run_switch_uplink(Side::Client, IpSupport::V4Only, IpSupport::V4Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_v4_to_v6() -> Result {
run_switch_uplink(Side::Client, IpSupport::V4Only, IpSupport::V6Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_v4_to_dual() -> Result {
run_switch_uplink(Side::Client, IpSupport::V4Only, IpSupport::DualStack).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_v6_to_v4() -> Result {
run_switch_uplink(Side::Client, IpSupport::V6Only, IpSupport::V4Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_v6_to_v6() -> Result {
run_switch_uplink(Side::Client, IpSupport::V6Only, IpSupport::V6Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_v6_to_dual() -> Result {
run_switch_uplink(Side::Client, IpSupport::V6Only, IpSupport::DualStack).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_dual_to_v4() -> Result {
run_switch_uplink(Side::Client, IpSupport::DualStack, IpSupport::V4Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_dual_to_v6() -> Result {
run_switch_uplink(Side::Client, IpSupport::DualStack, IpSupport::V6Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_client_dual_to_dual() -> Result {
run_switch_uplink(Side::Client, IpSupport::DualStack, IpSupport::DualStack).await
}
// --- Server switches uplink ---
#[tokio::test]
#[traced_test]
async fn switch_server_v4_to_v4() -> Result {
run_switch_uplink(Side::Server, IpSupport::V4Only, IpSupport::V4Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_v4_to_v6() -> Result {
run_switch_uplink(Side::Server, IpSupport::V4Only, IpSupport::V6Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_v4_to_dual() -> Result {
run_switch_uplink(Side::Server, IpSupport::V4Only, IpSupport::DualStack).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_v6_to_v4() -> Result {
run_switch_uplink(Side::Server, IpSupport::V6Only, IpSupport::V4Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_v6_to_v6() -> Result {
run_switch_uplink(Side::Server, IpSupport::V6Only, IpSupport::V6Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_v6_to_dual() -> Result {
run_switch_uplink(Side::Server, IpSupport::V6Only, IpSupport::DualStack).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_dual_to_v4() -> Result {
run_switch_uplink(Side::Server, IpSupport::DualStack, IpSupport::V4Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_dual_to_v6() -> Result {
run_switch_uplink(Side::Server, IpSupport::DualStack, IpSupport::V6Only).await
}
#[tokio::test]
#[traced_test]
async fn switch_server_dual_to_dual() -> Result {
run_switch_uplink(Side::Server, IpSupport::DualStack, IpSupport::DualStack).await
}
+531
View File
@@ -0,0 +1,531 @@
use std::{future::Future, path::PathBuf, sync::Arc, time::Duration};
use iroh::{
Endpoint, EndpointAddr, RelayMap, RelayMode, TransportAddr,
endpoint::{Connection, Path, PathEvent, presets},
tls::CaTlsConfig,
};
use iroh_metrics::MetricsGroupSet;
use n0_error::{Result, StackResultExt, StdResultExt, anyerr, ensure_any};
use n0_future::{StreamExt, boxed::BoxFuture, task::AbortOnDropHandle};
use noq::Side;
use patchbay::{Device, IpSupport, Lab, TestGuard};
use tokio::sync::{Barrier, oneshot};
use tracing::{Instrument, debug, error, error_span, event, info};
use self::relay::run_relay_server;
const TEST_ALPN: &[u8] = b"test";
/// Upper bound on waiting for the peer task at the end-of-run barrier in [`Pair::run`].
///
/// Generous compared to the run functions' own timeouts; it only triggers when the
/// peer task died before reaching the barrier.
const BARRIER_TIMEOUT: Duration = Duration::from_secs(30);
/// Creates a lab with a relay server.
///
/// Returns the lab, relay map, a drop guard that keeps the relay alive,
/// and a [`TestGuard`] that records pass/fail.
///
/// The relay binds on `[::]` and is reachable via `https://relay.test`
/// (resolved through lab-wide DNS entries for both IPv4 and IPv6).
pub(crate) async fn lab_with_relay(
outdir: PathBuf,
) -> Result<(Lab, RelayMap, AbortOnDropHandle<()>, TestGuard)> {
// `for_test` writes into `outdir`, labels the lab with the current thread
// (the test name under the default current-thread runtime), and returns the
// pass/fail guard.
let (lab, guard) = Lab::for_test(outdir).await?;
let (relay_map, relay_guard) = spawn_relay(&lab).await?;
Ok((lab, relay_map, relay_guard, guard))
}
/// Creates a router `dc` and device `relay` and spawns a relay server on the device.
///
/// Also creates a lab-wide DNS entry `relay.test` that resolves to the relay server's
/// IPv4 and IPv6 addresses.
///
/// Returns a [`RelayMap`] with an entry for the relay, and a drop handle that will
/// stop the relay server once dropped.
async fn spawn_relay(lab: &Lab) -> Result<(RelayMap, AbortOnDropHandle<()>)> {
let dc = lab
.add_router("dc")
.ip_support(IpSupport::DualStack)
.build()
.await?;
let dev_relay = lab.add_device("relay").uplink(dc.id()).build().await?;
// Register both v4 and v6 addresses under "relay.test" lab-wide.
// Devices created after this will resolve "relay.test" to both addresses.
let relay_v4 = dev_relay.ip().expect("relay has IPv4");
let relay_v6 = dev_relay.ip6().expect("relay has IPv6");
let dns = lab.dns_server()?;
dns.set_host("relay.test", relay_v4.into())?;
dns.set_host("relay.test", relay_v6.into())?;
info!(%relay_v4, %relay_v6, "DNS entries for relay.test registered");
let (relay_map_tx, relay_map_rx) = oneshot::channel();
let task_relay = dev_relay.spawn(async move |_ctx| {
let (relay_map, _server) = run_relay_server().await.unwrap();
relay_map_tx.send(relay_map).unwrap();
std::future::pending::<()>().await;
})?;
let relay_map = relay_map_rx.await.unwrap();
Ok((relay_map, AbortOnDropHandle::new(task_relay)))
}
/// Type alias for boxed run functions used in [`Pair`].
type RunFn = Box<dyn 'static + Send + FnOnce(Device, Endpoint, Connection) -> BoxFuture<Result>>;
fn box_fn<F, Fut>(f: F) -> RunFn
where
F: FnOnce(Device, Endpoint, Connection) -> Fut + Send + 'static,
Fut: Future<Output = Result> + Send + 'static,
{
Box::new(move |dev, ep, conn| Box::pin(f(dev, ep, conn)))
}
/// Builder for two connected endpoints in a lab.
///
/// Use this to quickly create two endpoints on two different devices and create a
/// connection between them that starts as relay-only.
///
/// Two construction paths:
///
/// ```ignore
/// // Explicit server/client assignment:
/// Pair::new(relay_map)
/// .server(server_dev, async |dev, ep, conn| { ... })
/// .client(client_dev, async |dev, ep, conn| { ... })
/// .run().await?;
///
/// // Side-swapped assignment (for matrix tests):
/// Pair::new(relay_map)
/// .left(some_side, dev_a, async |dev, ep, conn| { ... })
/// .right(dev_b, async |dev, ep, conn| { ... })
/// .run().await?;
/// ```
pub(crate) struct Pair {
relay_map: RelayMap,
server_dev: Option<Device>,
client_dev: Option<Device>,
server_fn: Option<RunFn>,
client_fn: Option<RunFn>,
}
impl Pair {
/// Creates a new pair builder with a shared [`RelayMap`].
pub(crate) fn new(relay_map: RelayMap) -> Self {
Self {
relay_map,
server_dev: None,
client_dev: None,
server_fn: None,
client_fn: None,
}
}
/// Places a device and closure on the given [`Side`].
///
/// Use with [`.right()`](Self::right) for matrix tests that swap sides.
pub(crate) fn left<F, Fut>(mut self, side: Side, device: Device, run_fn: F) -> Self
where
F: FnOnce(Device, Endpoint, Connection) -> Fut + Send + 'static,
Fut: Future<Output = Result> + Send + 'static,
{
let (dev_slot, fn_slot) = match side {
Side::Server => (&mut self.server_dev, &mut self.server_fn),
Side::Client => (&mut self.client_dev, &mut self.client_fn),
};
*dev_slot = Some(device);
*fn_slot = Some(box_fn(run_fn));
self
}
/// Places a device and closure on whichever [`Side`] was not set by [`.left()`](Self::left).
pub(crate) fn right<F, Fut>(self, device: Device, run_fn: F) -> Self
where
F: FnOnce(Device, Endpoint, Connection) -> Fut + Send + 'static,
Fut: Future<Output = Result> + Send + 'static,
{
let remaining = match (&self.server_dev, &self.client_dev) {
(Some(_), None) => Side::Client,
(None, Some(_)) => Side::Server,
(None, None) => panic!("call .left() before .right()"),
(Some(_), Some(_)) => panic!("both sides already assigned"),
};
self.left(remaining, device, run_fn)
}
/// Sets the server device and run function.
pub(crate) fn server<F, Fut>(mut self, device: Device, run_fn: F) -> Self
where
F: FnOnce(Device, Endpoint, Connection) -> Fut + Send + 'static,
Fut: Future<Output = Result> + Send + 'static,
{
self.server_dev = Some(device);
self.server_fn = Some(box_fn(run_fn));
self
}
/// Sets the client device and run function.
pub(crate) fn client<F, Fut>(mut self, device: Device, run_fn: F) -> Self
where
F: FnOnce(Device, Endpoint, Connection) -> Fut + Send + 'static,
Fut: Future<Output = Result> + Send + 'static,
{
self.client_dev = Some(device);
self.client_fn = Some(box_fn(run_fn));
self
}
/// Runs the pair to completion.
///
/// This will bind an endpoint on each device, wait for the server endpoint to be online,
/// then send a relay-only [`EndpointAddr`] to the client task.
/// The client task will connect to the server, and the server will accept a connection.
/// Once a connection is established on either side, its run function is invoked.
/// Once both run functions completed, the endpoints are dropped without awaiting
/// [`Endpoint::close`], so the corresponding ERROR logs are expected.
///
/// After completion, this will:
/// - log the result of the run functions
/// - record the endpoint metrics as a `patchbay::_metrics` tracing event
/// - emit a `test::_events::pass` or `test::_events::fail` event for each device
///
/// Returns an error if any step or run function failed.
pub(crate) async fn run(mut self) -> Result {
let server_device = self.server_dev.take().context("Missing server device")?;
let server_run = self
.server_fn
.take()
.context("Missing server run function")?;
let client_device = self.client_dev.take().context("Missing client device")?;
let client_run = self
.client_fn
.take()
.context("Missing client run function")?;
let (addr_tx, addr_rx) = oneshot::channel();
let relay_map2 = self.relay_map.clone();
// Create an in-memory synchronization barrier to wait for both run functions to complete
// before dropping endpoints. We use this to guarantee completion without awaiting
// `Endpoint::close` on both sides. `Endpoint::close` often takes several seconds,
// which increases test runtime for all tests significantly, and closing behavior
// should be tested for separately from the tests that use `Pair`.
//
// The barrier waits are bounded: a task that fails or panics before reaching the
// barrier would otherwise hang the healthy side until the nextest timeout kills
// the whole test instead of letting it report the failure.
let barrier_server = Arc::new(Barrier::new(2));
let barrier_client = barrier_server.clone();
let server_task = server_device.spawn(|dev| {
async move {
let endpoint = endpoint_builder(&dev, relay_map2)
.bind()
.await
.context("server endpoint bind")?;
info!(
id=%endpoint.id().fmt_short(),
bound_sockets=?endpoint.bound_sockets(),
"server endpoint bound",
);
endpoint.online().await;
info!("endpoint online");
// Send address to client task. Make it a relay-only address,
// like in the default address lookup services.
addr_tx.send(addr_relay_only(endpoint.addr())).unwrap();
let incoming = endpoint.accept().await.context("server accept incoming")?;
let conn = incoming
.accept()
.anyerr()?
.await
.context("server accept handshake")?;
info!(remote=%conn.remote_id().fmt_short(), "accepted, executing run function");
watch_selected_path(&conn);
let res = server_run(dev.clone(), endpoint.clone(), conn).await;
match &res {
Ok(()) => info!("run function completed successfully"),
Err(err) => error!("run function failed: {err:#}"),
}
// Wait until the client run function completed before dropping the endpoint.
let _ = tokio::time::timeout(BARRIER_TIMEOUT, barrier_server.wait()).await;
for group in endpoint.metrics().groups() {
dev.record_iroh_metrics(group);
}
res
}
.instrument(error_span!("ep-server"))
})?;
let client_task = client_device.spawn(move |dev| {
async move {
let endpoint = endpoint_builder(&dev, self.relay_map)
.bind()
.await
.context("client endpoint bind")?;
info!(
id=%endpoint.id().fmt_short(),
bound_sockets=?endpoint.bound_sockets(),
"client endpoint bound",
);
let addr = addr_rx
.await
.std_context("server did not send its address")?;
info!(?addr, "connecting to server");
let conn = endpoint
.connect(addr, TEST_ALPN)
.await
.context("client connect")?;
watch_selected_path(&conn);
info!(
remote=%conn.remote_id().fmt_short(),
"connected, executing run function",
);
let res = client_run(dev.clone(), endpoint.clone(), conn).await;
match &res {
Ok(()) => info!("run function completed successfully"),
Err(err) => error!("run function failed: {err:#}"),
}
// Wait until the server run function completed before dropping the endpoint.
let _ = tokio::time::timeout(BARRIER_TIMEOUT, barrier_client.wait()).await;
for group in endpoint.metrics().groups() {
dev.record_iroh_metrics(group);
}
res
}
.instrument(error_span!("ep-client"))
})?;
let (server_res, client_res) = tokio::join!(server_task, client_task);
// Map the results to include the device name, and emit a tracing event within the device context.
let [server_res, client_res] = [(&server_device, server_res), (&client_device, client_res)]
.map(|(dev, res)| {
let res = match res {
Err(err) => Err(anyerr!(err, "device {} panicked", dev.name())),
Ok(Err(err)) => Err(anyerr!(err, "device {} failed", dev.name())),
Ok(Ok(())) => Ok(()),
};
let res_str = res.as_ref().map_err(|err| format!("{err:#}")).cloned();
log_result_on_device(dev, res_str);
res
});
server_res?;
client_res?;
Ok(())
}
}
fn log_result_on_device<E: std::fmt::Display + Send + 'static>(dev: &Device, res: Result<(), E>) {
let _ = dev.run_sync(move || {
match res {
Ok(_) => event!(
target: "test::_events::pass",
tracing::Level::INFO,
msg = %"device passed"
),
Err(error) => event!(
target: "test::_events::fail",
tracing::Level::ERROR,
%error,
msg = %"device failed"
),
}
Ok(())
});
}
/// Extension trait on [`Connection`] providing timeout-bounded wait helpers
/// on top of [`Connection::paths`] and [`PathList::stream`].
pub(crate) trait PathConnectionExt {
/// Waits until the selected path satisfies `f`. Returns the matching
/// path's [`TransportAddr`].
async fn wait_selected(
&self,
timeout: Duration,
f: impl FnMut(&Path<'_>) -> bool,
) -> Result<TransportAddr>;
/// Waits until the selected path is a direct (IP) path.
async fn wait_ip(&self, timeout: Duration) -> Result<TransportAddr> {
self.wait_selected(timeout, |p| p.is_ip())
.await
.context("wait_ip")
}
}
impl PathConnectionExt for Connection {
async fn wait_selected(
&self,
timeout: Duration,
mut f: impl FnMut(&Path<'_>) -> bool,
) -> Result<TransportAddr> {
let mut stream = self.paths_stream();
tokio::time::timeout(timeout, async {
while let Some(paths) = stream.next().await {
let selected = paths
.iter()
.find(|p| p.is_selected())
.expect("no selected path");
if f(&selected) {
return Ok(selected.remote_addr().clone());
}
}
Err(anyerr!("path stream ended"))
})
.await
.with_std_context(|_| format!("wait_selected timed out after {timeout:?}"))?
}
}
/// Returns `true` if the currently selected path is a relay path.
pub(crate) fn is_relayed(conn: &iroh::endpoint::Connection) -> bool {
conn.paths()
.iter()
.find(|p| p.is_selected())
.expect("no selected path")
.is_relay()
}
/// Opens a bidi stream, sends 8 bytes of data, and waits to receive the same data back.
pub(crate) async fn ping_open(conn: &Connection, timeout: Duration) -> Result {
tokio::time::timeout(timeout, async {
let data: [u8; 8] = rand::random();
debug!("open_bi");
let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
debug!("write_all");
send.write_all(&data).await.anyerr()?;
send.finish().anyerr()?;
debug!("read_to_end");
let r = recv.read_to_end(8).await.anyerr()?;
ensure_any!(r == data, "reply matches");
debug!("done");
Ok(())
})
.instrument(error_span!("ping_open"))
.await
.with_std_context(|_| format!("ping_open timed out after {timeout:?}"))?
}
/// Accepts a bidi stream, reads 8 bytes of data, and sends the same data back.
pub(crate) async fn ping_accept(conn: &Connection, timeout: Duration) -> Result {
tokio::time::timeout(timeout, async {
debug!("accept_bi");
let (mut send, mut recv) = conn.accept_bi().await.anyerr()?;
debug!("read_to_end");
let data = recv.read_to_end(8).await.anyerr()?;
debug!("write_all");
send.write_all(&data).await.anyerr()?;
send.finish().anyerr()?;
debug!("done");
Ok(())
})
.instrument(error_span!("ping_accept"))
.await
.with_std_context(|_| format!("ping_accept timed out after {timeout:?}"))?
}
fn watch_selected_path(conn: &Connection) {
let mut events = conn.path_events();
if let Some(path) = conn.paths().iter().find(|p| p.is_selected()) {
debug!("selected path: [{}] {}", path.id(), path.remote_addr());
}
tokio::spawn(
async move {
while let Some(event) = events.next().await {
if let PathEvent::Selected {
id, remote_addr, ..
} = event
{
debug!("selected path: [{id}] {remote_addr}");
}
}
}
.instrument(tracing::Span::current()),
);
}
fn endpoint_builder(device: &Device, relay_map: RelayMap) -> iroh::endpoint::Builder {
#[allow(unused_mut)]
let mut builder = Endpoint::builder(presets::Minimal)
.relay_mode(RelayMode::Custom(relay_map))
.ca_tls_config(CaTlsConfig::insecure_skip_verify())
.alpns(vec![TEST_ALPN.to_vec()]);
#[cfg(not(feature = "qlog"))]
let _ = device;
#[cfg(feature = "qlog")]
{
if let Some(path) = device.filepath("qlog") {
let prefix = path.file_name().unwrap().to_str().unwrap();
let directory = path.parent().unwrap();
let transport_config = iroh::endpoint::QuicTransportConfig::builder()
.qlog_from_path(directory, prefix)
.build();
builder = builder.transport_config(transport_config);
}
}
builder
}
fn addr_relay_only(addr: EndpointAddr) -> EndpointAddr {
EndpointAddr::from_parts(addr.id, addr.addrs.into_iter().filter(|a| a.is_relay()))
}
pub(crate) mod relay {
use std::{
net::{IpAddr, Ipv6Addr},
sync::Arc,
};
use iroh_base::RelayUrl;
use iroh_relay::{
RelayConfig, RelayMap, RelayQuicConfig,
server::{
AllowAll, CertConfig, QuicConfig, RelayConfig as RelayServerConfig, Server,
ServerConfig, SpawnError, TlsConfig, testing::self_signed_tls_certs_and_config,
},
};
/// Spawn a relay server bound on `[::]` that accepts both IPv4 and IPv6.
///
/// The returned [`RelayMap`] uses `https://relay.test` as the relay URL.
/// Callers are responsible for ensuring that a DNS entry for `relay.test`
/// exists and points to the relay's IP addresses.
pub(crate) async fn run_relay_server() -> Result<(RelayMap, Server), SpawnError> {
let bind_ip: IpAddr = Ipv6Addr::UNSPECIFIED.into();
let (_certs, server_config) = self_signed_tls_certs_and_config();
let tls = TlsConfig::new((bind_ip, 443), CertConfig::Manual { server_config });
let mut relay = RelayServerConfig::new((bind_ip, 80));
relay.tls = Some(tls);
relay.key_cache_capacity = Some(1024);
relay.access = Arc::new(AllowAll);
let mut config = ServerConfig::default();
config.relay = Some(relay);
config.quic = Some(QuicConfig::new((bind_ip, 7842)));
let server = Server::spawn(config).await?;
let url: RelayUrl = "https://relay.test".parse().expect("valid relay url");
let quic = server
.quic_addr()
.map(|addr| RelayQuicConfig::new(addr.port()));
let relay_map: RelayMap = RelayConfig::new(url, quic).into();
Ok((relay_map, server))
}
}