feat(network): add relay connection policies

This commit is contained in:
2026-07-23 15:14:03 +02:00
parent cbace73908
commit a0bcc5dbff
40 changed files with 942 additions and 254 deletions

View File

@@ -12,7 +12,9 @@ pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum CoreRelayMode {
Automatic,
Custom,
StrictCustom,
CustomWithDirectFallback,
LocalOnly,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
@@ -33,13 +35,21 @@ impl Default for CoreNetworkConfig {
impl CoreNetworkConfig {
pub(crate) fn validated_relay_urls(&self) -> anyhow::Result<Vec<RelayUrl>> {
match self.mode {
CoreRelayMode::Automatic => {
CoreRelayMode::Automatic | CoreRelayMode::LocalOnly => {
if !self.relay_urls.is_empty() {
anyhow::bail!("automatic relay mode must not include custom relay URLs");
anyhow::bail!(
"{} relay mode must not include custom relay URLs",
match self.mode {
CoreRelayMode::Automatic => "automatic",
CoreRelayMode::LocalOnly => "local-only",
CoreRelayMode::StrictCustom
| CoreRelayMode::CustomWithDirectFallback => unreachable!(),
}
);
}
Ok(Vec::new())
}
CoreRelayMode::Custom => {
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
if self.relay_urls.is_empty() {
anyhow::bail!("custom relay mode requires at least one relay URL");
}

View File

@@ -61,6 +61,23 @@ use crate::{
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RelayStatus {
Disabled,
Connected,
Unreachable,
}
impl RelayStatus {
fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Connected => "connected",
Self::Unreachable => "unreachable",
}
}
}
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
/// Kotlin owns app lifecycle and platform file picking.
pub(super) struct CoreInner {
@@ -122,7 +139,7 @@ impl CoreInner {
.bind()
.await?
}
CoreRelayMode::Custom => {
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
let relay_map = RelayMap::from_iter(relay_urls.iter().cloned().map(|url| {
// Loopback HTTP is a development escape hatch. Without TLS the
// relay cannot serve Iroh's QUIC address-discovery endpoint.
@@ -141,13 +158,22 @@ impl CoreInner {
.bind()
.await?
}
CoreRelayMode::LocalOnly => {
Endpoint::builder(presets::Minimal)
.relay_mode(RelayMode::Disabled)
.secret_key(secret_key)
.bind()
.await?
}
};
if let Err(error) =
wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await
{
endpoint.close().await;
return Err(error);
}
let relay_status =
match wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await {
Ok(status) => status,
Err(error) => {
endpoint.close().await;
return Err(error);
}
};
// Provider events are where the sender sees remote readers. The core
// uses them for send progress and for the current approval gate.
@@ -160,6 +186,14 @@ impl CoreInner {
limits.event_queue_capacity as usize,
limits.max_events,
));
event_hub.emit_endpoint(
"network",
"relay-status",
json!({
"mode": relay_mode_label(relay_mode),
"status": relay_status.as_str(),
}),
);
for recovered in recovered_transfers {
event_hub.emit_transfer(
recovered.transfer_id,
@@ -377,7 +411,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode(
) -> Result<EndpointAddr> {
match relay_mode {
CoreRelayMode::Automatic => Ok(addr.clone()),
CoreRelayMode::Custom => {
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
@@ -396,6 +430,16 @@ pub(crate) fn filter_peer_addr_for_relay_mode(
}
Ok(filtered)
}
CoreRelayMode::LocalOnly => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
if filtered.is_empty() {
anyhow::bail!("invitation has no direct address allowed by local-only mode");
}
Ok(filtered)
}
}
}
@@ -404,17 +448,16 @@ pub(crate) async fn wait_for_relay(
relay_mode: CoreRelayMode,
relay_urls: &[RelayUrl],
timeout: Duration,
) -> Result<()> {
) -> Result<RelayStatus> {
if relay_mode == CoreRelayMode::LocalOnly {
return Ok(RelayStatus::Disabled);
}
if tokio::time::timeout(timeout, endpoint.online())
.await
.is_err()
{
match relay_mode {
CoreRelayMode::Automatic => anyhow::bail!(
"timed out after {} seconds while connecting to automatic relays; verify network access and relay availability",
timeout.as_secs_f32(),
),
CoreRelayMode::Custom => {
CoreRelayMode::StrictCustom => {
let configured_relays = relay_urls
.iter()
.map(ToString::to_string)
@@ -425,9 +468,22 @@ pub(crate) async fn wait_for_relay(
timeout.as_secs_f32(),
);
}
CoreRelayMode::Automatic | CoreRelayMode::CustomWithDirectFallback => {
return Ok(RelayStatus::Unreachable);
}
CoreRelayMode::LocalOnly => unreachable!(),
}
}
Ok(())
Ok(RelayStatus::Connected)
}
fn relay_mode_label(relay_mode: CoreRelayMode) -> &'static str {
match relay_mode {
CoreRelayMode::Automatic => "automatic",
CoreRelayMode::StrictCustom => "strict-custom",
CoreRelayMode::CustomWithDirectFallback => "custom-with-direct-fallback",
CoreRelayMode::LocalOnly => "local-only",
}
}
pub(super) fn share_tag_name(local_id: &str) -> String {

View File

@@ -7,7 +7,7 @@ use crate::{
default_core_network_config, CoreNetworkConfig, CoreRelayMode, MAX_CUSTOM_RELAYS,
MAX_RELAY_URL_BYTES,
},
runtime::{filter_peer_addr_for_relay_mode, wait_for_relay},
runtime::{filter_peer_addr_for_relay_mode, wait_for_relay, RelayStatus},
};
#[test]
@@ -32,17 +32,28 @@ fn relay_mode_and_url_list_must_be_consistent() {
};
assert!(automatic_with_url.validated_relay_urls().is_err());
let custom_without_url = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: Vec::new(),
for mode in [
CoreRelayMode::StrictCustom,
CoreRelayMode::CustomWithDirectFallback,
] {
let custom_without_url = CoreNetworkConfig {
mode,
relay_urls: Vec::new(),
};
assert!(custom_without_url.validated_relay_urls().is_err());
}
let local_only_with_url = CoreNetworkConfig {
mode: CoreRelayMode::LocalOnly,
relay_urls: vec!["https://relay.example.com".to_string()],
};
assert!(custom_without_url.validated_relay_urls().is_err());
assert!(local_only_with_url.validated_relay_urls().is_err());
}
#[test]
fn custom_relay_urls_allow_https_and_loopback_http() {
let config = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![
"https://relay.example.com".to_string(),
"http://localhost:3340".to_string(),
@@ -68,7 +79,7 @@ fn custom_relay_urls_reject_unsafe_or_ambiguous_values() {
" https://relay.example.com",
] {
let config = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![value.to_string()],
};
assert!(
@@ -81,7 +92,7 @@ fn custom_relay_urls_reject_unsafe_or_ambiguous_values() {
#[test]
fn custom_relay_urls_are_bounded_and_unique_after_normalization() {
let duplicates = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![
"https://relay.example.com".to_string(),
"https://relay.example.com/".to_string(),
@@ -90,7 +101,7 @@ fn custom_relay_urls_are_bounded_and_unique_after_normalization() {
assert!(duplicates.validated_relay_urls().is_err());
let too_many = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: (0..=MAX_CUSTOM_RELAYS)
.map(|index| format!("https://relay-{index}.example.com"))
.collect(),
@@ -98,7 +109,7 @@ fn custom_relay_urls_are_bounded_and_unique_after_normalization() {
assert!(too_many.validated_relay_urls().is_err());
let too_long = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![format!(
"https://{}.example.com",
"a".repeat(MAX_RELAY_URL_BYTES)
@@ -119,7 +130,7 @@ fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() {
let filtered = filter_peer_addr_for_relay_mode(
&addr,
CoreRelayMode::Custom,
CoreRelayMode::StrictCustom,
std::slice::from_ref(&allowed),
)
.unwrap();
@@ -140,14 +151,32 @@ fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() {
EndpointAddr::new(SecretKey::generate().public()).with_relay_url(disallowed);
assert!(filter_peer_addr_for_relay_mode(
&disallowed_only,
CoreRelayMode::Custom,
CoreRelayMode::StrictCustom,
std::slice::from_ref(&allowed),
)
.is_err());
let fallback_filtered = filter_peer_addr_for_relay_mode(
&addr,
CoreRelayMode::CustomWithDirectFallback,
std::slice::from_ref(&allowed),
)
.unwrap();
assert_eq!(fallback_filtered, filtered);
let local_only = filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::LocalOnly, &[]).unwrap();
assert_eq!(local_only.relay_urls().count(), 0);
assert_eq!(
local_only.ip_addrs().copied().collect::<Vec<_>>(),
vec![direct]
);
assert!(
filter_peer_addr_for_relay_mode(&disallowed_only, CoreRelayMode::LocalOnly, &[]).is_err()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() {
async fn relay_wait_enforces_only_strict_custom_mode() {
let relay_url: RelayUrl = "http://127.0.0.1:9".parse().unwrap();
let endpoint = Endpoint::builder(presets::Minimal)
.relay_mode(RelayMode::custom([relay_url.clone()]))
@@ -158,7 +187,7 @@ async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() {
let error = wait_for_relay(
&endpoint,
CoreRelayMode::Custom,
CoreRelayMode::StrictCustom,
std::slice::from_ref(&relay_url),
Duration::from_millis(50),
)
@@ -169,18 +198,35 @@ async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() {
assert!(error.to_string().contains(relay_url.as_str()));
assert!(error.to_string().contains("verify the URLs"));
let automatic_error = wait_for_relay(
let automatic_status = wait_for_relay(
&endpoint,
CoreRelayMode::Automatic,
&[],
Duration::from_millis(50),
)
.await
.unwrap_err();
.unwrap();
assert!(started.elapsed() < Duration::from_secs(1));
assert!(automatic_error.to_string().contains("automatic relays"));
assert!(automatic_error
.to_string()
.contains("verify network access"));
assert_eq!(automatic_status, RelayStatus::Unreachable);
let fallback_status = wait_for_relay(
&endpoint,
CoreRelayMode::CustomWithDirectFallback,
std::slice::from_ref(&relay_url),
Duration::from_millis(50),
)
.await
.unwrap();
assert_eq!(fallback_status, RelayStatus::Unreachable);
let local_only_status = wait_for_relay(
&endpoint,
CoreRelayMode::LocalOnly,
&[],
Duration::from_millis(50),
)
.await
.unwrap();
assert_eq!(local_only_status, RelayStatus::Disabled);
endpoint.close().await;
}

View File

@@ -120,14 +120,21 @@ fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() {
assert!(ticket_matches_relay_profile(
&custom_ticket,
&limits,
CoreRelayMode::Custom,
CoreRelayMode::StrictCustom,
&[relay_b.clone(), relay_a.clone()],
)
.unwrap());
assert!(ticket_matches_relay_profile(
&custom_ticket,
&limits,
CoreRelayMode::CustomWithDirectFallback,
&[relay_b.clone(), relay_a.clone()],
)
.unwrap());
assert!(!ticket_matches_relay_profile(
&custom_ticket,
&limits,
CoreRelayMode::Custom,
CoreRelayMode::StrictCustom,
&[relay_a.clone(), relay_c],
)
.unwrap());
@@ -145,10 +152,21 @@ fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() {
assert!(!ticket_matches_relay_profile(
&automatic_ticket,
&limits,
CoreRelayMode::Custom,
CoreRelayMode::StrictCustom,
&[relay_a],
)
.unwrap());
assert!(ticket_matches_relay_profile(
&automatic_ticket,
&limits,
CoreRelayMode::LocalOnly,
&[],
)
.unwrap());
assert!(
!ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::LocalOnly, &[],)
.unwrap()
);
}
#[test]

View File

@@ -142,7 +142,7 @@ pub(crate) fn parse_transfer_ticket_with_limits(
Vec::new()
} else {
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: ticket.relay_urls,
}
.validated_relay_urls()
@@ -171,7 +171,7 @@ pub(crate) fn ticket_matches_relay_profile(
let parsed = parse_transfer_ticket_with_limits(value, limits)?;
match relay_mode {
CoreRelayMode::Automatic => Ok(parsed.advertised_custom_relay_urls.is_empty()),
CoreRelayMode::Custom => {
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
let advertised = parsed
.advertised_custom_relay_urls
.into_iter()
@@ -179,6 +179,8 @@ pub(crate) fn ticket_matches_relay_profile(
let configured = custom_relay_urls.iter().cloned().collect::<BTreeSet<_>>();
Ok(advertised == configured)
}
CoreRelayMode::LocalOnly => Ok(parsed.advertised_custom_relay_urls.is_empty()
&& parsed.blob_ticket.addr().relay_urls().next().is_none()),
}
}

View File

@@ -11,24 +11,64 @@ use vnidrop::{CoreNetworkConfig, CoreRelayMode};
fn custom_config(relay_urls: &[&str]) -> CoreNetworkConfig {
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
}
}
fn local_only_config() -> CoreNetworkConfig {
CoreNetworkConfig {
mode: CoreRelayMode::LocalOnly,
relay_urls: Vec::new(),
}
}
fn read_blob_ticket(ticket: &str) -> (Value, BlobTicket) {
let encoded = ticket.strip_prefix("vnd1:").unwrap();
let payload = BASE64URL_NOPAD.decode(encoded.as_bytes()).unwrap();
let value: Value = serde_json::from_slice(&payload).unwrap();
let blob_ticket = BlobTicket::from_str(value["blob_ticket"].as_str().unwrap()).unwrap();
let (mut addr, hash, format) = blob_ticket.into_parts();
for relay_url in value["relay_urls"].as_array().unwrap() {
addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap());
if let Some(relay_urls) = value["relay_urls"].as_array() {
for relay_url in relay_urls {
addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap());
}
}
let blob_ticket = BlobTicket::new(addr, hash, format);
(value, blob_ticket)
}
#[test]
fn local_only_mode_advertises_direct_addresses_and_transfers_on_lan() {
let sender = TestNode::with_network_config(local_only_config());
let receiver = TestNode::with_network_config(local_only_config());
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("local-only.txt");
std::fs::write(&source_path, b"direct on the local network").unwrap();
let share = share_path(&sender.core, &source_path, 402, "local-only.txt", false);
let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket);
assert!(ticket_value.get("relay_urls").is_none());
assert_eq!(blob_ticket.addr().relay_urls().count(), 0);
assert!(!sender.core.status().addr.contains("iroh.link"));
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_dir.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("local-only.txt")).unwrap(),
b"direct on the local network"
);
}
fn with_relay_only_address(ticket: &str, relay_url: &str) -> String {
let (mut value, blob_ticket) = read_blob_ticket(ticket);
let relay_url: RelayUrl = relay_url.parse().unwrap();

View File

@@ -151,7 +151,7 @@ fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() {
core_dir.path(),
Arc::new(RecordingSink::default()),
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![relay_a.url.clone()],
},
);
@@ -163,7 +163,7 @@ fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() {
core_dir.path(),
Arc::new(RecordingSink::default()),
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
mode: CoreRelayMode::StrictCustom,
relay_urls: vec![relay_b.url.clone()],
},
);