Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a68a89a
refactor: add Close frame to relay protocol
Frando Feb 9, 2026
b6f08b7
fix test
Frando Feb 9, 2026
e15cd96
docs
Frando Feb 9, 2026
6d80b55
fixup
Frando Feb 9, 2026
8a0e0b2
fixup
Frando Feb 9, 2026
1668159
fixup tests
Frando Feb 9, 2026
5daad90
chore: cleanup
Frando Feb 9, 2026
2ce353e
fix: keep relay connections alive even when a new endpoint with the s…
Frando Feb 10, 2026
8cd8d3a
Revert "refactor: add Close frame to relay protocol"
Frando Feb 10, 2026
f4322c4
fixup test
Frando Feb 11, 2026
485acd4
fixup
Frando Feb 11, 2026
32a45e7
Merge branch 'main' into Frando/refactor-relay-close
Frando Feb 11, 2026
cfb4403
cleanup errors
Frando Feb 11, 2026
1e74662
more cleanup
Frando Feb 11, 2026
b2112f8
chore: clippy
Frando Feb 11, 2026
be5cd37
improve naming
Frando Feb 12, 2026
d3402af
Merge branch 'main' into Frando/refactor-relay-close
Frando Feb 12, 2026
a2002e5
fix: don't rely on timings alone for established relay connection
Frando Feb 13, 2026
0bbb6cd
Merge branch 'main' into Frando/refactor-relay-close
Frando Feb 16, 2026
79501bf
refactor: reactive inactive client if active client disconnects
Frando Feb 17, 2026
265c6e1
add test
Frando Feb 17, 2026
cf8f92e
Merge remote-tracking branch 'origin/main' into Frando/refactor-relay…
Frando Feb 17, 2026
04cd398
cleanup
Frando Feb 17, 2026
7cf0ed9
Merge remote-tracking branch 'origin/main' into Frando/refactor-relay…
Frando Feb 18, 2026
ef15092
refactor: add iroh-relay-v2 protocol
Frando Feb 17, 2026
52a4783
cleanup, add test
Frando Feb 18, 2026
a7f6338
fixup
Frando Feb 18, 2026
1470e8b
Merge remote-tracking branch 'origin/main' into Frando/relay-protocol-v2
Frando Mar 3, 2026
195bf7b
cleanup
Frando Mar 3, 2026
f0291eb
add test for protocol negotiation
Frando Mar 3, 2026
57cbb3e
cleanup
Frando Mar 3, 2026
3021264
add HealthStatus::Healthy variant
Frando Mar 3, 2026
8c50276
fixup tests
Frando Mar 3, 2026
235282b
fix: ordering of relay versions when sending request from client
Frando Mar 3, 2026
8aaec00
fixup outdated comment
Frando Mar 5, 2026
6462066
Merge remote-tracking branch 'origin/main' into Frando/relay-protocol-v2
Frando Mar 5, 2026
aecabee
fixup wasm
Frando Mar 5, 2026
e3bb955
fix: wrong comment
Frando Mar 6, 2026
a995f21
chore: clippy
Frando Mar 9, 2026
bc27dc1
Merge remote-tracking branch 'origin/main' into Frando/relay-protocol-v2
Frando Mar 9, 2026
3440fcd
chore: clippy
Frando Mar 9, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions iroh-relay/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub use self::conn::{RecvError, SendError};
use crate::dns::{DnsError, DnsResolver};
use crate::{
KeyCache,
http::RELAY_PATH,
http::{ProtocolVersion, RELAY_PATH},
protos::{
handshake,
relay::{ClientToRelayMsg, RelayToClientMsg},
Expand Down Expand Up @@ -207,7 +207,7 @@ impl ClientBuilder {
use tls::MaybeTlsStreamBuilder;

use crate::{
http::{CLIENT_AUTH_HEADER, RELAY_PROTOCOL_VERSION},
http::CLIENT_AUTH_HEADER,
protos::{handshake::KeyMaterialClientAuth, relay::MAX_FRAME_SIZE},
tls::{CaRootsConfig, default_provider},
};
Expand Down Expand Up @@ -255,7 +255,7 @@ impl ClientBuilder {
})?
.add_header(
SEC_WEBSOCKET_PROTOCOL,
http::HeaderValue::from_static(RELAY_PROTOCOL_VERSION),
ProtocolVersion::all_as_header_value(),
)
.expect("valid header name and value")
.limits(tokio_websockets::Limits::default().max_payload_len(Some(MAX_FRAME_SIZE)))
Expand Down Expand Up @@ -312,8 +312,6 @@ impl ClientBuilder {
/// Establishes a new connection to the relay server.
#[cfg(wasm_browser)]
pub async fn connect(&self) -> Result<Client, ConnectError> {
use crate::http::RELAY_PROTOCOL_VERSION;

let mut dial_url = (*self.url).clone();
dial_url.set_path(RELAY_PATH);
// The relay URL is exchanged with the http(s) scheme in tickets and similar.
Expand All @@ -332,9 +330,11 @@ impl ClientBuilder {

debug!(%dial_url, "Dialing relay by websocket");

let (_, ws_stream) =
ws_stream_wasm::WsMeta::connect(dial_url.as_str(), Some(vec![RELAY_PROTOCOL_VERSION]))
.await?;
let (_, ws_stream) = ws_stream_wasm::WsMeta::connect(
dial_url.as_str(),
Some(ProtocolVersion::all().collect()),
)
.await?;
let conn = Conn::new(ws_stream, self.key_cache.clone(), &self.secret_key).await?;

event!(
Expand Down
94 changes: 88 additions & 6 deletions iroh-relay/src/http.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! HTTP-specific constants for the relay server and client.

use http::HeaderName;
use http::{HeaderName, HeaderValue};
use n0_error::stack_error;
use strum::VariantArray;

#[cfg(feature = "server")]
pub(crate) const WEBSOCKET_UPGRADE_PROTOCOL: &str = "websocket";
Expand All @@ -13,10 +15,90 @@ pub const RELAY_PATH: &str = "/relay";
/// The HTTP path under which the relay allows doing latency queries for testing.
pub const RELAY_PROBE_PATH: &str = "/ping";

/// The websocket sub-protocol version that we currently support.
///
/// This is sent as the websocket sub-protocol header `Sec-Websocket-Protocol` from
/// the client and answered from the server.
pub const RELAY_PROTOCOL_VERSION: &str = "iroh-relay-v1";
/// The HTTP header name for relay client authentication
pub const CLIENT_AUTH_HEADER: HeaderName = HeaderName::from_static("x-iroh-relay-client-auth-v1");

/// The relay protocol version negotiated between client and server.
///
/// Sent as the websocket sub-protocol header `Sec-Websocket-Protocol` from
/// the client. The server picks the best supported version and replies with it.
///
/// Variants are ordered by preference (highest first), so the [`Ord`] impl
/// can be used during negotiation to pick the best version.
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
Default,
strum::VariantArray,
strum::EnumString,
strum::Display,
strum::IntoStaticStr,
)]
#[strum(parse_err_ty = UnsupportedRelayProtocolVersion, parse_err_fn = strum_err_fn)]
// Needs to be ordered with latest version last, so that the `Ord` impl orders by latest version as max.
pub enum ProtocolVersion {
/// Version 1 (before iroh 0.97.0)
#[strum(serialize = "iroh-relay-v1")]
V1,
/// Version 2 (added in iroh 0.97.0)
/// - Removed `Health` frame (id 11)
/// - Added new `Status` frame (id 13)
/// - Changed behavior such that unknown frames are allowed
#[default]
#[strum(serialize = "iroh-relay-v2")]
V2,
}

impl ProtocolVersion {
/// Returns an iterator of all supported protocol version identifiers, in order of preference.
pub fn all() -> impl Iterator<Item = &'static str> {
Self::VARIANTS
.iter()
.map(ProtocolVersion::to_str)
// We reverse the order so that the latest version comes first:
// `Self::VARIANTS` is ordered in definition order, where the latest version comes last
// so that the `Ord` derive correctly orderes by "latest is max".
.rev()
}

/// Returns a comma-separated string of all supported protocol version identifiers.
pub fn all_joined() -> String {
Self::all().collect::<Vec<_>>().join(", ")
}

/// Returns all supported protocol versions in a comma-seperated string as an HTTP header value.
pub fn all_as_header_value() -> HeaderValue {
HeaderValue::from_bytes(Self::all_joined().as_bytes()).expect("valid header name")
}

/// Returns the protocol version identifier string.
pub fn to_str(&self) -> &'static str {
self.into()
}

/// Tries to parse a [`ProtocolVersion`] from `s`.
///
/// Returns `None` if `s` is not a valid protocol version string.
pub fn match_from_str(s: &str) -> Option<Self> {
Self::try_from(s).ok()
}

/// Returns this protocol version as an HTTP header value.
pub fn to_header_value(&self) -> HeaderValue {
HeaderValue::from_static(self.to_str())
}
}

/// Error returned when the relay protocol version is not recognized.
#[stack_error(derive)]
#[error("Relay protocol version is not supported")]
pub struct UnsupportedRelayProtocolVersion;

fn strum_err_fn(_item: &str) -> UnsupportedRelayProtocolVersion {
UnsupportedRelayProtocolVersion::new()
}
11 changes: 11 additions & 0 deletions iroh-relay/src/protos/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pub enum FrameType {
Ping = 9,
/// 8 byte payload, the contents of ping being replied to
Pong = 10,
/// REMOVED since relay-protocol-v2, use `Self::Status` instead.
///
/// Sent from server to client to tell the client if their connection is unhealthy somehow.
/// Contains only UTF-8 bytes.
Health = 11,
Expand All @@ -53,6 +55,15 @@ pub enum FrameType {
/// Payload is two big endian u32 durations in milliseconds: when to reconnect,
/// and how long to try total.
Restarting = 12,

/// Sent from server to client to declare the connection health state.
///
/// Added in `iroh-relay-v2` protocol. May not be sent to `iroh-relay-v1` clients.
///
/// Uses a binary-encoded [`HealthStatus`] payload.
///
/// [`HealthStatus`]: super::relay::HealthStatus
Status = 13,
}

#[stack_error(derive, add_meta)]
Expand Down
105 changes: 91 additions & 14 deletions iroh-relay/src/protos/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ pub enum Error {
}

/// The messages that a relay sends to clients or the clients receive from the relay.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, strum::Display)]
#[non_exhaustive]
pub enum RelayToClientMsg {
/// Represents datagrams sent from relays (originally sent to them by another client).
Datagrams {
Expand All @@ -84,15 +85,7 @@ pub enum RelayToClientMsg {
/// packet but has now disconnected from the relay.
EndpointGone(EndpointId),
/// A one-way message from relay to client, declaring the connection health state.
Health {
/// If set, is a description of why the connection is unhealthy.
///
/// If `None` means the connection is healthy again.
///
/// The default condition is healthy, so the relay doesn't broadcast a [`RelayToClientMsg::Health`]
/// until a problem exists.
problem: String,
},
Status(HealthStatus),
/// A one-way message from relay to client, advertising that the relay is restarting.
Restarting {
/// An advisory duration that the client should wait before attempting to reconnect.
Expand All @@ -110,6 +103,66 @@ pub enum RelayToClientMsg {
/// Reply to a [`ClientToRelayMsg::Ping`] from a client
/// with the payload sent previously in the ping.
Pong([u8; 8]),

// -- Deprecated variants --
// We don't use `#[deprecated]` because this would throw warnings for the derived serde impls.
/// Removed since relay-protocol-v2:
/// A one-way message from relay to client, declaring the connection health state.
///
/// Use [`Self::Status`] instead.
Health {
/// If set, is a description of why the connection is unhealthy.
///
/// If `None` means the connection is healthy again.
///
/// The default condition is healthy, so the relay doesn't broadcast a [`RelayToClientMsg::Health`]
/// until a problem exists.
problem: String,
},
}

/// One-way message from server to client indicating issues with the relay connection.
#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)]
#[non_exhaustive]
pub enum HealthStatus {
/// The connection is healthy and recovered from previous problems.
#[display("The connection is healthy and has recovered from previous problems")]
Healthy,
/// Another endpoint connected with the same endpoint id. No more messages will be received.
#[display(
"Another endpoint connected with the same endpoint id. No more messages will be received."
)]
SameEndpointIdConnected,
/// Placeholder for backwards-compatibility for future new health status variants.
#[display("Unsupported health message ({_0})")]
Unknown(u8),
}

impl HealthStatus {
#[cfg(feature = "server")]
fn write_to<O: BufMut>(&self, mut dst: O) -> O {
match self {
HealthStatus::Healthy => dst.put_u8(0),
HealthStatus::SameEndpointIdConnected => dst.put_u8(1),
HealthStatus::Unknown(discriminant) => dst.put_u8(*discriminant),
}
dst
}

#[cfg(feature = "server")]
fn encoded_len(&self) -> usize {
1
}

fn from_bytes(mut bytes: Bytes) -> Result<Self, Error> {
ensure!(!bytes.is_empty(), Error::InvalidFrame);
let discriminant = bytes.get_u8();
match discriminant {
0 => Ok(Self::Healthy),
1 => Ok(Self::SameEndpointIdConnected),
n => Ok(Self::Unknown(n)),
}
}
}

/// Messages that clients send to relays.
Expand Down Expand Up @@ -257,8 +310,9 @@ impl RelayToClientMsg {
Self::EndpointGone { .. } => FrameType::EndpointGone,
Self::Ping { .. } => FrameType::Ping,
Self::Pong { .. } => FrameType::Pong,
Self::Health { .. } => FrameType::Health,
Self::Status { .. } => FrameType::Status,
Self::Restarting { .. } => FrameType::Restarting,
Self::Health { .. } => FrameType::Health,
}
}

Expand Down Expand Up @@ -300,6 +354,9 @@ impl RelayToClientMsg {
dst.put_u32(reconnect_in.as_millis() as u32);
dst.put_u32(try_for.as_millis() as u32);
}
Self::Status(status) => {
dst = status.write_to(dst);
}
}
dst
}
Expand All @@ -313,11 +370,12 @@ impl RelayToClientMsg {
}
Self::EndpointGone(_) => 32,
Self::Ping(_) | Self::Pong(_) => 8,
Self::Health { problem } => problem.len(),
Self::Status(status) => status.encoded_len(),
Self::Restarting { .. } => {
4 // u32
+ 4 // u32
}
Self::Health { problem } => problem.len(),
};
self.typ().encoded_len() + payload_len
}
Expand Down Expand Up @@ -388,6 +446,10 @@ impl RelayToClientMsg {
try_for,
}
}
FrameType::Status => {
let status = HealthStatus::from_bytes(content)?;
Self::Status(status)
}
_ => {
return Err(e!(Error::InvalidFrameType { frame_type }));
}
Expand Down Expand Up @@ -599,6 +661,11 @@ mod tests {
.write_to(Vec::new()),
"0c 00 00 00 0a 00 00 00 14",
),
(
RelayToClientMsg::Status(HealthStatus::SameEndpointIdConnected)
.write_to(Vec::new()),
"0d 01",
),
]);

Ok(())
Expand Down Expand Up @@ -719,18 +786,28 @@ mod proptests {
let endpoint_gone = key().prop_map(RelayToClientMsg::EndpointGone);
let ping = prop::array::uniform8(any::<u8>()).prop_map(RelayToClientMsg::Ping);
let pong = prop::array::uniform8(any::<u8>()).prop_map(RelayToClientMsg::Pong);
let health = ".{0,65536}"
let v1health = ".{0,65536}"
.prop_filter("exceeds MAX_PACKET_SIZE", |s| {
s.len() < MAX_PACKET_SIZE // a single unicode character can match a regex "." but take up multiple bytes
})
.prop_map(|problem| RelayToClientMsg::Health { problem });
let health = Just(HealthStatus::SameEndpointIdConnected)
.prop_map(RelayToClientMsg::Status);
let restarting = (any::<u32>(), any::<u32>()).prop_map(|(reconnect_in, try_for)| {
RelayToClientMsg::Restarting {
reconnect_in: Duration::from_millis(reconnect_in.into()),
try_for: Duration::from_millis(try_for.into()),
}
});
prop_oneof![recv_packet, endpoint_gone, ping, pong, health, restarting]
prop_oneof![
recv_packet,
endpoint_gone,
ping,
pong,
v1health,
restarting,
health
]
}

fn client_server_frame() -> impl Strategy<Value = ClientToRelayMsg> {
Expand Down
Loading
Loading