Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## [Unreleased]

- Transport tuning and relay redundancy:
- BBRv3 congestion control on both backends (paced, bufferbloat-
resistant) instead of the loss-based Cubic default — better
latency under load for interactive desktop and bulk sync.
- 4 MiB stream receive window / 32 MiB connection send window
(upstream defaults target ~100 Mbps × 100 ms): large keyframes and
sync chunk streams no longer stall on high-BDP links.
- `--relay` is repeatable on `rds` and `rds-agent`; endpoints probe
all configured relays, home on the fastest and fail over
automatically — the iroh-recommended ≥2-relay production topology.
- Relay TLS and lifecycle polish:
- `rds-server`/`rds-relay` gain native TLS on the relay listener:
`--tls-cert/--tls-key` for PEM files (rustls `ring` provider), or
Expand Down
9 changes: 4 additions & 5 deletions crates/rds-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ struct Cli {
/// Path to the endpoint secret key (created if missing).
#[arg(long)]
key_file: Option<std::path::PathBuf>,
/// Custom relay URL; default is the n0 public relays.
/// Custom relay URL; default is the n0 public relays. Repeatable —
/// ≥2 relays give automatic client-side failover.
#[arg(long)]
relay: Option<String>,
relay: Vec<String>,
/// Transport backend: `iroh` (default) or `noq` (with the
/// `transport-noq` feature).
#[arg(long, default_value = "iroh")]
Expand Down Expand Up @@ -111,9 +112,7 @@ async fn main() -> anyhow::Result<()> {
backend,
..Default::default()
};
if let Some(url) = &cli.relay {
config = config.with_relay(url)?;
}
config = config.with_relays(&cli.relay)?;

let endpoint = bind_endpoint(config).await?;
endpoint.online().await;
Expand Down
4 changes: 2 additions & 2 deletions crates/rds-agent/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ async fn unauthorized_peer_is_rejected() {
async fn direct_connection_without_relay() {
// LAN-style path: no relay, direct UDP addresses in the ticket.
let agent_ep = bind_endpoint(EndpointConfig {
relay: None,
relays: Vec::new(),
..Default::default()
})
.await
Expand Down Expand Up @@ -554,7 +554,7 @@ async fn sync_unconfigured_is_refused() {
async fn direct_connection_noq_backend() {
let config = || EndpointConfig {
backend: rds_net::Backend::Noq,
relay: None,
relays: Vec::new(),
..Default::default()
};
let agent_ep = bind_endpoint(config()).await.unwrap();
Expand Down
9 changes: 4 additions & 5 deletions crates/rds-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ struct Cli {
/// Path to the endpoint secret key.
#[arg(long, global = true)]
key_file: Option<std::path::PathBuf>,
/// Custom relay URL; default is the n0 public relays.
/// Custom relay URL; default is the n0 public relays. Repeatable —
/// ≥2 relays give automatic client-side failover.
#[arg(long, global = true)]
relay: Option<String>,
relay: Vec<String>,
/// Transport backend: `iroh` (default) or `noq` (with the
/// `transport-noq` feature).
#[arg(long, global = true, default_value = "iroh")]
Expand Down Expand Up @@ -127,9 +128,7 @@ async fn main() -> anyhow::Result<()> {
backend,
..Default::default()
};
if let Some(url) = &cli.relay {
config = config.with_relay(url)?;
}
config = config.with_relays(&cli.relay)?;
let endpoint = bind_endpoint(config).await?;
let directory = cli.server.map(rds_discovery::client::Client::new);
let grant = cli
Expand Down
5 changes: 3 additions & 2 deletions crates/rds-net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ data-encoding.workspace = true
ed25519-dalek = { workspace = true, features = ["pkcs8"] }
iroh.workspace = true
noq = { workspace = true, optional = true }
noq-proto = { workspace = true, optional = true }
# Non-optional: iroh runs on noq internally, so it is in the tree either
# way — needed for transport tuning (BBRv3, windows) on the iroh backend.
noq-proto.workspace = true
rand = { workspace = true, optional = true }
postcard = { workspace = true, features = ["alloc", "use-std"] }
rds-core.workspace = true
Expand All @@ -37,7 +39,6 @@ metrics = []
## Owned transport backend on noq (WS1). Off by default until C1 parity.
transport-noq = [
"dep:noq",
"dep:noq-proto",
"dep:tokio-stream",
"dep:blake3",
"dep:rand",
Expand Down
32 changes: 22 additions & 10 deletions crates/rds-net/src/backends/iroh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,35 @@ use crate::EndpointConfig;
/// lookup — so a private deployment does not publish to third-party DNS.
/// Without one, `presets::N0` gives the public relays plus DNS/Pkarr lookup.
pub async fn bind_endpoint(config: EndpointConfig) -> anyhow::Result<Endpoint> {
let mut builder = match (&config.relay, config.discovery) {
(Some(url), _) => Endpoint::builder(iroh::endpoint::presets::Minimal)
.relay_mode(RelayMode::Custom(RelayMap::from_iter([url.clone()]))),
(None, true) => Endpoint::builder(iroh::endpoint::presets::N0),
let mut builder = match (config.relays.is_empty(), config.discovery) {
(false, _) => Endpoint::builder(iroh::endpoint::presets::Minimal).relay_mode(
RelayMode::Custom(RelayMap::from_iter(config.relays.clone())),
),
(true, true) => Endpoint::builder(iroh::endpoint::presets::N0),
// No relay, no lookup: Minimal binds a plain QUIC socket.
(None, false) => Endpoint::builder(iroh::endpoint::presets::Minimal),
(true, false) => Endpoint::builder(iroh::endpoint::presets::Minimal),
};
if let Some(key) = config.secret_key {
builder = builder.secret_key(key);
}
// Tuning on top of iroh's multipath-aware defaults:
// - BBRv3: paced, bufferbloat-resistant — the low-latency choice for
// interactive desktop + bulk sync over real WAN paths (upstream
// default is loss-based Cubic).
// - 4 MiB stream receive window: upstream tunes for ~100 Mbps x
// 100 ms; a larger per-stream window keeps a big keyframe or sync
// chunk stream from stalling on high-BDP links.
// - 32 MiB connection send window keeps several bulk streams busy.
let mut transport = iroh::endpoint::QuicTransportConfig::builder()
.congestion_controller_factory(std::sync::Arc::new(
noq_proto::congestion::Bbr3Config::default(),
))
.stream_receive_window(noq_proto::VarInt::from_u32(4 * 1024 * 1024))
.send_window(32 * 1024 * 1024);
if let Some(max_paths) = config.max_multipath_paths {
builder = builder.transport_config(
iroh::endpoint::QuicTransportConfig::builder()
.max_concurrent_multipath_paths(max_paths)
.build(),
);
transport = transport.max_concurrent_multipath_paths(max_paths);
}
builder = builder.transport_config(transport.build());
// iroh manages its own sockets; a single bind address is all it
// accepts. Multi-interface binding is a `noq`-backend capability.
if let Some(addr) = config.bind_addrs.first() {
Expand Down
6 changes: 6 additions & 0 deletions crates/rds-net/src/backends/noq/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ fn transport_config(max_multipath_paths: Option<u32>) -> Arc<noq::TransportConfi
cfg.server_handshake_migration(true);
cfg.datagram_receive_buffer_size(Some(DATAGRAM_BUFFER_SIZE));
cfg.datagram_send_buffer_size(DATAGRAM_BUFFER_SIZE);
// Same tuning as the iroh backend: BBRv3 pacing for latency +
// bufferbloat resistance, and windows above the 100Mbps x 100ms
// defaults so bulk streams do not stall on high-BDP links.
cfg.congestion_controller_factory(Arc::new(noq_proto::congestion::Bbr3Config::default()));
cfg.stream_receive_window(noq_proto::VarInt::from_u32(4 * 1024 * 1024));
cfg.send_window(32 * 1024 * 1024);
Arc::new(cfg)
}

Expand Down
24 changes: 19 additions & 5 deletions crates/rds-net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ pub struct EndpointConfig {
/// bind multiple interfaces on backends that support socket muxing
/// (`noq`); single-socket backends use the first entry.
pub bind_addrs: Vec<SocketAddr>,
/// Custom relay URL. `None` uses the backend's default relay set
/// (n0 public relays for iroh).
pub relay: Option<RelayUrl>,
/// Custom relay URLs. Empty uses the backend's default relay set
/// (n0 public relays for iroh). Multiple relays give the client
/// automatic failover — production deployments should run ≥2.
pub relays: Vec<RelayUrl>,
/// Publish/resolve addresses via the backend's lookup services
/// (iroh: n0 DNS + pkarr). `false` binds the `Minimal` preset —
/// dialing uses exactly the `EndpointAddr` given, which is what
Expand Down Expand Up @@ -116,7 +117,7 @@ impl Default for EndpointConfig {
backend: Backend::default(),
secret_key: None,
bind_addrs: Vec::new(),
relay: None,
relays: Vec::new(),
discovery: true,
max_multipath_paths: None,
#[cfg(feature = "transport-noq")]
Expand All @@ -130,7 +131,20 @@ impl EndpointConfig {
/// Relay URL string, e.g. `https://relay.example.com` or `http://127.0.0.1:3340`.
pub fn with_relay(mut self, url: &str) -> anyhow::Result<Self> {
use std::str::FromStr;
self.relay = Some(RelayUrl::from_str(url)?);
self.relays.push(RelayUrl::from_str(url)?);
Ok(self)
}

/// Multiple relay URLs — the client fails over between them.
pub fn with_relays<I, S>(mut self, urls: I) -> anyhow::Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
use std::str::FromStr;
for url in urls {
self.relays.push(RelayUrl::from_str(url.as_ref())?);
}
Ok(self)
}

Expand Down
10 changes: 9 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,17 @@ connection.
### Stability measures

- Relay-first connect (works on any egress-only network), in-band
hole-punch upgrade — both handled by iroh.
hole-punch upgrade — both handled by iroh. `--relay` is repeatable:
multiple custom relays give automatic client-side failover, which is
the iroh-recommended production topology (≥2 relays).
- QUIC connection migration survives NAT rebinding/Wi-Fi↔LTE moves.
- Agent reconnects to relay with backoff; CLI can pin `--relay`.
- QUIC transport tuning on both backends: BBRv3 congestion control
(paced, bufferbloat-resistant — vs loss-based Cubic default),
4 MiB stream receive window / 32 MiB connection send window so a
large keyframe or sync chunk stream does not stall on high-BDP
links (upstream defaults target ~100 Mbps × 100 ms). iroh's own
multipath keep-alive and path idle-timeout defaults are preserved.
- Serialized frame sends + collapse + mid-send stale reset bound
worst-case latency under loss: queues stay near-empty and the
residual tail is retransmit physics, not queueing.
Expand Down
7 changes: 7 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ table inet rds {
accepts direct paths opportunistically (hole-punched or via the
endpoint's discovered addresses).

**Relay redundancy.** `--relay` is repeatable on both `rds` and
`rds-agent`; iroh probes all configured relays, homes on the
lowest-latency one, and fails over automatically. Production should run
≥2 `rds-relay`/`rds-server` instances in different failure domains and
list all their URLs — no LB or failover plumbing is needed on the relay
side, since every endpoint carries the full list.

**TLS on the relay.** `rds-server` serves the iroh relay protocol
(WebSocket over HTTP) on 3340 in plaintext: relayed payloads are
end-to-end-encrypted QUIC the relay cannot read, and relay admission is
Expand Down
Loading