feat(network): support custom relay servers

Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
This commit is contained in:
2026-07-23 14:27:40 +02:00
parent 5939489432
commit cbace73908
66 changed files with 5692 additions and 168 deletions

View File

@@ -36,4 +36,5 @@ uuid = { version = "1.23.3", features = ["v4", "serde"] }
walkdir = "2.5.0"
[dev-dependencies]
iroh-relay = { version = "1.0.0", features = ["server"] }
tempfile = "3.27.0"

View File

@@ -1,9 +1,121 @@
use anyhow::Context;
use iroh::RelayUrl;
use iroh_blobs::Hash;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, net::IpAddr, str::FromStr};
use crate::util::{non_empty, now_ms};
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
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,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct CoreNetworkConfig {
pub mode: CoreRelayMode,
pub relay_urls: Vec<String>,
}
impl Default for CoreNetworkConfig {
fn default() -> Self {
Self {
mode: CoreRelayMode::Automatic,
relay_urls: Vec::new(),
}
}
}
impl CoreNetworkConfig {
pub(crate) fn validated_relay_urls(&self) -> anyhow::Result<Vec<RelayUrl>> {
match self.mode {
CoreRelayMode::Automatic => {
if !self.relay_urls.is_empty() {
anyhow::bail!("automatic relay mode must not include custom relay URLs");
}
Ok(Vec::new())
}
CoreRelayMode::Custom => {
if self.relay_urls.is_empty() {
anyhow::bail!("custom relay mode requires at least one relay URL");
}
if self.relay_urls.len() > MAX_CUSTOM_RELAYS {
anyhow::bail!(
"custom relay mode supports at most {MAX_CUSTOM_RELAYS} relay URLs"
);
}
let mut seen = BTreeSet::new();
let mut validated = Vec::with_capacity(self.relay_urls.len());
for (index, value) in self.relay_urls.iter().enumerate() {
if value.is_empty()
|| value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
anyhow::bail!(
"relay URL must be non-empty and contain no whitespace or control characters"
);
}
if value.len() > MAX_RELAY_URL_BYTES {
anyhow::bail!(
"relay URL is {} bytes, limit is {MAX_RELAY_URL_BYTES}",
value.len()
);
}
let url = RelayUrl::from_str(value)
.with_context(|| format!("invalid relay URL at position {}", index + 1))?;
let secure = url.scheme() == "https";
let loopback_http = url.scheme() == "http"
&& url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
.is_ok_and(|address| address.is_loopback())
});
if !secure && !loopback_http {
anyhow::bail!(
"relay URL must use HTTPS; HTTP is allowed only for loopback development relays"
);
}
if url.host_str().is_none() {
anyhow::bail!("relay URL must include a host");
}
if url.port() == Some(0) {
anyhow::bail!("relay URL port must be between 1 and 65535");
}
if value.contains('@') || !url.username().is_empty() || url.password().is_some()
{
anyhow::bail!("relay URL must not contain credentials");
}
if url.query().is_some() || url.fragment().is_some() {
anyhow::bail!("relay URL must not contain a query or fragment");
}
if url.path() != "/" {
anyhow::bail!("relay URL must not contain a path");
}
if !seen.insert(url.clone()) {
anyhow::bail!("custom relay URLs must not contain duplicates");
}
validated.push(url);
}
Ok(validated)
}
}
}
}
#[uniffi::export]
pub fn default_core_network_config() -> CoreNetworkConfig {
CoreNetworkConfig::default()
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreLimits {
pub max_sources: u64,

View File

@@ -14,10 +14,11 @@ mod transfer_state;
mod util;
pub use api::{
default_core_limits, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, PublishedOutput,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TicketInspection, TransferAccessMode, TransferMetadata,
default_core_limits, default_core_network_config, CoreEvent, CoreEventSink, CoreLimits,
CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, ReceiveOutputSink,
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection,
TransferAccessMode, TransferMetadata,
};
pub use error::VnidropError;
pub use runtime::VnidropCore;

View File

@@ -49,6 +49,7 @@ pub(crate) struct PersistedShare {
pub(crate) transfer_id: u64,
pub(crate) local_id: String,
pub(crate) content_hash: String,
pub(crate) ticket: Option<String>,
pub(crate) access_mode: String,
}
@@ -709,7 +710,7 @@ impl Repository {
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
let rows = sqlx::query(
r#"
SELECT transfer_id, local_id, content_hash, access_mode
SELECT transfer_id, local_id, content_hash, ticket, access_mode
FROM transfers
WHERE direction = 'send'
AND status = 'sharing'
@@ -724,7 +725,8 @@ impl Repository {
transfer_id: row.get::<i64, _>(0) as u64,
local_id: row.get::<String, _>(1),
content_hash: row.get::<String, _>(2),
access_mode: row.get::<String, _>(3),
ticket: row.get::<Option<String>, _>(3),
access_mode: row.get::<String, _>(4),
})
.collect())
}

View File

@@ -1,12 +1,12 @@
use std::{str::FromStr, sync::Arc, time::Duration};
use std::{sync::Arc, time::Duration};
use iroh_blobs::ticket::BlobTicket;
use serde_json::json;
use super::CoreInner;
use super::{filter_peer_addr_for_relay_mode, CoreInner};
use crate::{
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
repository::PendingDeliveryReceipt,
ticket::parse_persisted_sender_address,
};
const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -60,8 +60,8 @@ impl CoreInner {
}
async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) {
let blob_ticket = match BlobTicket::from_str(&pending.sender_blob_ticket) {
Ok(ticket) => ticket,
let sender_addr = match parse_persisted_sender_address(&pending.sender_blob_ticket) {
Ok(addr) => addr,
Err(error) => {
tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt");
let _ = self
@@ -78,7 +78,24 @@ impl CoreInner {
return;
}
};
let client = HandshakeService::client(self.endpoint.clone(), blob_ticket.addr().clone());
let sender_addr = match filter_peer_addr_for_relay_mode(
&sender_addr,
self.relay_mode,
&self.custom_relay_urls,
) {
Ok(addr) => addr,
Err(error) => {
self.emit_transfer(
pending.local_transfer_id,
"receive",
"delivery",
"receipt-failed",
json!({ "reason": error.to_string() }),
);
return;
}
};
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
let receipt = DeliveryReceipt {
request_id: pending.request_id.clone(),
transfer_id: pending.sender_transfer_id,

View File

@@ -6,9 +6,10 @@ use serde_json::json;
use super::CoreInner;
use crate::{
api::{
CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink,
ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput,
ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
TransferAccessMode,
},
error::VnidropError,
filesystem::platform_path,
@@ -42,7 +43,26 @@ impl VnidropCore {
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_limits(app_data_dir, event_sink, CoreLimits::default())
Self::initialize_with_limits_and_network_config(
app_data_dir,
event_sink,
CoreLimits::default(),
CoreNetworkConfig::default(),
)
}
#[uniffi::constructor]
pub fn initialize_with_network_config(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_limits_and_network_config(
app_data_dir,
event_sink,
CoreLimits::default(),
network_config,
)
}
#[uniffi::constructor]
@@ -50,15 +70,39 @@ impl VnidropCore {
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_limits_and_network_config(
app_data_dir,
event_sink,
limits,
CoreNetworkConfig::default(),
)
}
#[uniffi::constructor]
pub fn initialize_with_limits_and_network_config(
app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
limits.validate().map_err(VnidropError::initialization)?;
let relay_urls = network_config
.validated_relay_urls()
.map_err(VnidropError::initialization)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("vnidrop")
.build()?;
let app_data_dir = PathBuf::from(app_data_dir);
let inner = runtime
.block_on(CoreInner::start(app_data_dir, event_sink, limits))
.block_on(CoreInner::start(
app_data_dir,
event_sink,
limits,
network_config.mode,
relay_urls,
))
.map_err(VnidropError::initialization)?;
Ok(Arc::new(Self { runtime, inner }))
}

View File

@@ -27,7 +27,10 @@ use std::{
use anyhow::Result;
use futures_lite::StreamExt as _;
use iroh::{endpoint::presets, protocol::Router, Endpoint};
use iroh::{
endpoint::presets, protocol::Router, tls::CaTlsConfig, Endpoint, EndpointAddr, RelayConfig,
RelayMap, RelayMode, RelayUrl,
};
use iroh_blobs::{
format::collection::Collection,
provider::events::{EventMask, EventSender},
@@ -45,16 +48,19 @@ use tokio::{
use crate::{
access_policy::{mode_from_storage, AccessPolicy},
api::{CoreEvent, CoreEventSink, CoreLimits},
api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode},
approval::ApprovalService,
event_hub::EventHub,
handshake::HandshakeService,
logging::init_logging,
repository::Repository,
secret::load_or_create_secret,
ticket::ticket_matches_relay_profile,
transfer_state::{TransferDirection, TransferStatus},
};
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
/// Kotlin owns app lifecycle and platform file picking.
pub(super) struct CoreInner {
@@ -66,6 +72,8 @@ pub(super) struct CoreInner {
pub(super) event_hub: Arc<EventHub>,
pub(super) approval: ApprovalService,
pub(super) limits: CoreLimits,
pub(super) relay_mode: CoreRelayMode,
pub(super) custom_relay_urls: Vec<RelayUrl>,
pub(super) transfer_slots: Semaphore,
pub(super) access_policy: Arc<AccessPolicy>,
/// Sync mutex so cancel can remove + signal without awaiting (and without
@@ -93,6 +101,8 @@ impl CoreInner {
app_data_dir: PathBuf,
event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits,
relay_mode: CoreRelayMode,
relay_urls: Vec<RelayUrl>,
) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&app_data_dir).await?;
init_logging(&app_data_dir)?;
@@ -105,11 +115,39 @@ impl CoreInner {
add_protected: None,
});
let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?;
let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key)
.bind()
.await?;
endpoint.online().await;
let endpoint = match relay_mode {
CoreRelayMode::Automatic => {
Endpoint::builder(presets::N0)
.secret_key(secret_key)
.bind()
.await?
}
CoreRelayMode::Custom => {
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.
if url.scheme() == "http" {
RelayConfig::new(url, None)
} else {
RelayConfig::from(url)
}
}));
// Minimal leaves address lookup empty, so strict custom mode
// cannot silently publish or resolve addresses through N0.
Endpoint::builder(presets::Minimal)
.relay_mode(RelayMode::Custom(relay_map))
.ca_tls_config(CaTlsConfig::embedded())
.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);
}
// Provider events are where the sender sees remote readers. The core
// uses them for send progress and for the current approval gate.
@@ -189,6 +227,27 @@ impl CoreInner {
);
continue;
};
let relay_profile_matches = share.ticket.as_deref().is_some_and(|ticket| {
ticket_matches_relay_profile(ticket, &limits, relay_mode, &relay_urls)
.unwrap_or(false)
});
if !relay_profile_matches {
repository
.transition_transfer_status(
transfer_id,
TransferStatus::Sharing,
TransferStatus::Stopped,
)
.await?;
event_hub.emit_transfer(
transfer_id,
TransferDirection::Send.as_str(),
"recovery",
"share-stopped-network-profile-changed",
json!({ "reason": "saved ticket does not match the active relay profile" }),
);
continue;
}
let tag_name = share_tag_name(&share.local_id);
store
.tags()
@@ -239,6 +298,8 @@ impl CoreInner {
repository,
event_hub,
approval,
relay_mode,
custom_relay_urls: relay_urls,
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
limits,
access_policy,
@@ -309,6 +370,66 @@ impl CoreInner {
}
}
pub(crate) fn filter_peer_addr_for_relay_mode(
addr: &EndpointAddr,
relay_mode: CoreRelayMode,
custom_relay_urls: &[RelayUrl],
) -> Result<EndpointAddr> {
match relay_mode {
CoreRelayMode::Automatic => Ok(addr.clone()),
CoreRelayMode::Custom => {
let mut filtered = EndpointAddr::new(addr.id);
for ip_addr in addr.ip_addrs().copied() {
filtered = filtered.with_ip_addr(ip_addr);
}
for relay_url in addr
.relay_urls()
.filter(|relay_url| custom_relay_urls.contains(relay_url))
.cloned()
{
filtered = filtered.with_relay_url(relay_url);
}
if filtered.is_empty() {
anyhow::bail!(
"invitation has no direct address or relay allowed by strict custom relay mode"
);
}
Ok(filtered)
}
}
}
pub(crate) async fn wait_for_relay(
endpoint: &Endpoint,
relay_mode: CoreRelayMode,
relay_urls: &[RelayUrl],
timeout: Duration,
) -> Result<()> {
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 => {
let configured_relays = relay_urls
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"timed out after {} seconds while connecting to custom relays [{configured_relays}]; verify the URLs, TLS certificates, network access, and relay availability",
timeout.as_secs_f32(),
);
}
}
}
Ok(())
}
pub(super) fn share_tag_name(local_id: &str) -> String {
format!("vnidrop/share/{local_id}")
}

View File

@@ -9,12 +9,12 @@ use bytes::Bytes;
use futures_lite::StreamExt as _;
use iroh_blobs::{
api::proto::ExportRangesItem, api::remote::GetProgressItem, format::collection::Collection,
get::request::get_hash_seq_and_sizes, Hash,
get::request::get_hash_seq_and_sizes, ticket::BlobTicket, Hash,
};
use serde_json::json;
use tokio::sync::oneshot;
use super::{ActiveTransfer, CoreInner};
use super::{filter_peer_addr_for_relay_mode, ActiveTransfer, CoreInner};
use crate::{
access_policy::mode_to_storage,
api::{
@@ -28,7 +28,9 @@ use crate::{
},
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
ticket::{
encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket,
},
transfer_state::{TransferDirection, TransferStatus},
};
@@ -192,7 +194,7 @@ impl CoreInner {
.await
.context("transfer limiter is closed")
.map_err(VnidropError::internal)?;
let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
let mut parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
.context("failed to parse transfer ticket")
{
Ok(parsed) => parsed,
@@ -205,6 +207,17 @@ impl CoreInner {
return Err(error);
}
};
let sender_addr = filter_peer_addr_for_relay_mode(
parsed.blob_ticket.addr(),
self.relay_mode,
&self.custom_relay_urls,
)
.map_err(VnidropError::network)?;
parsed.blob_ticket = BlobTicket::new(
sender_addr,
parsed.blob_ticket.hash(),
parsed.blob_ticket.format(),
);
let transfer_id = parsed.metadata.transfer_id;
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
.await
@@ -271,7 +284,8 @@ impl CoreInner {
.map_err(VnidropError::filesystem)?;
}
let sender_addr = parsed.blob_ticket.addr().clone();
let sender_blob_ticket = parsed.blob_ticket.to_string();
let persisted_sender_address = encode_persisted_sender_address(&sender_addr)
.context("failed to encode sender address for delivery receipt")?;
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
// Every VniDrop ticket carries metadata and must complete the handshake.
@@ -355,7 +369,7 @@ impl CoreInner {
self.repository
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
local_transfer_id: transfer_id,
sender_blob_ticket: &sender_blob_ticket,
sender_blob_ticket: &persisted_sender_address,
request_id: &delivery_receipt.request_id,
sender_transfer_id: delivery_receipt.transfer_id,
token: &delivery_receipt.token,

View File

@@ -149,9 +149,13 @@ impl CoreInner {
import.file_count,
import.total_size,
);
let ticket = VnidropTicket::new(blob_ticket, ticket_metadata)
.encode()
.context("failed to encode VniDrop transfer ticket")?;
let ticket = VnidropTicket::new_with_relay_urls(
blob_ticket,
ticket_metadata,
&self.custom_relay_urls,
)
.encode()
.context("failed to encode VniDrop transfer ticket")?;
let content_hash = import.root_hash.to_string();
let local_id = self
.repository

View File

@@ -8,6 +8,8 @@ mod filesystem_tests;
mod handshake_tests;
#[path = "tests/limits.rs"]
mod limits_tests;
#[path = "tests/network_config.rs"]
mod network_config_tests;
#[path = "tests/repository.rs"]
mod repository_tests;
#[path = "tests/runtime.rs"]

View File

@@ -0,0 +1,186 @@
use std::time::{Duration, Instant};
use iroh::{endpoint::presets, Endpoint, EndpointAddr, RelayMode, RelayUrl, SecretKey};
use crate::{
api::{
default_core_network_config, CoreNetworkConfig, CoreRelayMode, MAX_CUSTOM_RELAYS,
MAX_RELAY_URL_BYTES,
},
runtime::{filter_peer_addr_for_relay_mode, wait_for_relay},
};
#[test]
fn default_network_config_uses_automatic_relays() {
assert_eq!(
default_core_network_config(),
CoreNetworkConfig {
mode: CoreRelayMode::Automatic,
relay_urls: Vec::new(),
}
);
default_core_network_config()
.validated_relay_urls()
.unwrap();
}
#[test]
fn relay_mode_and_url_list_must_be_consistent() {
let automatic_with_url = CoreNetworkConfig {
mode: CoreRelayMode::Automatic,
relay_urls: vec!["https://relay.example.com".to_string()],
};
assert!(automatic_with_url.validated_relay_urls().is_err());
let custom_without_url = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: Vec::new(),
};
assert!(custom_without_url.validated_relay_urls().is_err());
}
#[test]
fn custom_relay_urls_allow_https_and_loopback_http() {
let config = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: vec![
"https://relay.example.com".to_string(),
"http://localhost:3340".to_string(),
"http://127.0.0.1:3341".to_string(),
"http://[::1]:3342".to_string(),
],
};
assert_eq!(config.validated_relay_urls().unwrap().len(), 4);
}
#[test]
fn custom_relay_urls_reject_unsafe_or_ambiguous_values() {
for value in [
"http://relay.example.com",
"https://user:password@relay.example.com",
"https://relay.example.com/path",
"https://relay.example.com?token=secret",
"https://relay.example.com#fragment",
"https://relay.example.com:0",
"https://relay.exa\tmple.com",
"https://@relay.example.com",
" https://relay.example.com",
] {
let config = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: vec![value.to_string()],
};
assert!(
config.validated_relay_urls().is_err(),
"{value} should be rejected"
);
}
}
#[test]
fn custom_relay_urls_are_bounded_and_unique_after_normalization() {
let duplicates = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: vec![
"https://relay.example.com".to_string(),
"https://relay.example.com/".to_string(),
],
};
assert!(duplicates.validated_relay_urls().is_err());
let too_many = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: (0..=MAX_CUSTOM_RELAYS)
.map(|index| format!("https://relay-{index}.example.com"))
.collect(),
};
assert!(too_many.validated_relay_urls().is_err());
let too_long = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: vec![format!(
"https://{}.example.com",
"a".repeat(MAX_RELAY_URL_BYTES)
)],
};
assert!(too_long.validated_relay_urls().is_err());
}
#[test]
fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() {
let allowed: RelayUrl = "https://allowed.relay.example.com".parse().unwrap();
let disallowed: RelayUrl = "https://disallowed.relay.example.com".parse().unwrap();
let direct = "192.0.2.1:4433".parse().unwrap();
let addr = EndpointAddr::new(SecretKey::generate().public())
.with_relay_url(allowed.clone())
.with_relay_url(disallowed.clone())
.with_ip_addr(direct);
let filtered = filter_peer_addr_for_relay_mode(
&addr,
CoreRelayMode::Custom,
std::slice::from_ref(&allowed),
)
.unwrap();
assert_eq!(
filtered.relay_urls().cloned().collect::<Vec<_>>(),
vec![allowed.clone()]
);
assert_eq!(
filtered.ip_addrs().copied().collect::<Vec<_>>(),
vec![direct]
);
assert_eq!(
filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::Automatic, &[]).unwrap(),
addr
);
let disallowed_only =
EndpointAddr::new(SecretKey::generate().public()).with_relay_url(disallowed);
assert!(filter_peer_addr_for_relay_mode(
&disallowed_only,
CoreRelayMode::Custom,
std::slice::from_ref(&allowed),
)
.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_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()]))
.bind()
.await
.unwrap();
let started = Instant::now();
let error = wait_for_relay(
&endpoint,
CoreRelayMode::Custom,
std::slice::from_ref(&relay_url),
Duration::from_millis(50),
)
.await
.unwrap_err();
assert!(started.elapsed() < Duration::from_secs(1));
assert!(error.to_string().contains(relay_url.as_str()));
assert!(error.to_string().contains("verify the URLs"));
let automatic_error = wait_for_relay(
&endpoint,
CoreRelayMode::Automatic,
&[],
Duration::from_millis(50),
)
.await
.unwrap_err();
assert!(started.elapsed() < Duration::from_secs(1));
assert!(automatic_error.to_string().contains("automatic relays"));
assert!(automatic_error
.to_string()
.contains("verify network access"));
endpoint.close().await;
}

View File

@@ -80,6 +80,7 @@ async fn persists_transfers_and_events_across_reopen() {
assert_eq!(shares.len(), 1);
assert_eq!(shares[0].transfer_id, 7);
assert_eq!(shares[0].content_hash, "hash");
assert_eq!(shares[0].ticket.as_deref(), Some("ticket"));
assert_eq!(shares[0].access_mode, "approval_required");
repository

View File

@@ -1,11 +1,16 @@
use std::net::{Ipv4Addr, SocketAddr};
use data_encoding::BASE64URL_NOPAD;
use iroh::SecretKey;
use iroh::{RelayUrl, SecretKey};
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
use serde_json::json;
use crate::{
api::{CoreLimits, TransferMetadata},
ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket},
api::{CoreLimits, CoreRelayMode, TransferMetadata},
ticket::{
encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket,
parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket,
},
};
fn blob_ticket(hash_byte: u8) -> BlobTicket {
@@ -25,7 +30,7 @@ fn metadata_ticket_round_trips() {
3,
2048,
);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone())
let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata.clone(), &[])
.encode()
.unwrap();
let parsed = parse_transfer_ticket(&encoded).unwrap();
@@ -38,7 +43,7 @@ fn metadata_ticket_round_trips() {
fn metadata_ticket_tolerates_wrapped_whitespace() {
let blob_ticket = blob_ticket(9);
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata)
let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata, &[])
.encode()
.unwrap();
let wrapped = encoded
@@ -52,6 +57,129 @@ fn metadata_ticket_tolerates_wrapped_whitespace() {
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
}
#[test]
fn metadata_ticket_restores_backup_relays_without_losing_direct_addresses() {
let secret = SecretKey::generate();
let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap();
let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap();
let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49152));
let addr = iroh::EndpointAddr::new(secret.public())
.with_relay_url(primary.clone())
.with_ip_addr(direct);
let blob_ticket = BlobTicket::new(addr, Hash::new([11; 32]), BlobFormat::HashSeq);
let metadata = TransferMetadata::new(11, "Backed up", None, blob_ticket.hash(), 1, 10);
let encoded = VnidropTicket::new_with_relay_urls(
blob_ticket,
metadata,
&[primary.clone(), backup.clone()],
)
.encode()
.unwrap();
let parsed = parse_transfer_ticket(&encoded).unwrap();
assert_eq!(
parsed
.blob_ticket
.addr()
.relay_urls()
.cloned()
.collect::<Vec<_>>(),
vec![primary, backup]
);
assert_eq!(
parsed
.blob_ticket
.addr()
.ip_addrs()
.copied()
.collect::<Vec<_>>(),
vec![direct]
);
}
#[test]
fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() {
let relay_a: RelayUrl = "https://a.relay.example.com".parse().unwrap();
let relay_b: RelayUrl = "https://b.relay.example.com".parse().unwrap();
let relay_c: RelayUrl = "https://c.relay.example.com".parse().unwrap();
let blob_ticket = blob_ticket(13);
let metadata = TransferMetadata::new(13, "Relay profile", None, blob_ticket.hash(), 1, 10);
let custom_ticket = VnidropTicket::new_with_relay_urls(
blob_ticket.clone(),
metadata.clone(),
&[relay_a.clone(), relay_b.clone()],
)
.encode()
.unwrap();
let automatic_ticket = VnidropTicket::new_with_relay_urls(blob_ticket, metadata, &[])
.encode()
.unwrap();
let limits = CoreLimits::default();
assert!(ticket_matches_relay_profile(
&custom_ticket,
&limits,
CoreRelayMode::Custom,
&[relay_b.clone(), relay_a.clone()],
)
.unwrap());
assert!(!ticket_matches_relay_profile(
&custom_ticket,
&limits,
CoreRelayMode::Custom,
&[relay_a.clone(), relay_c],
)
.unwrap());
assert!(
!ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::Automatic, &[],)
.unwrap()
);
assert!(ticket_matches_relay_profile(
&automatic_ticket,
&limits,
CoreRelayMode::Automatic,
&[],
)
.unwrap());
assert!(!ticket_matches_relay_profile(
&automatic_ticket,
&limits,
CoreRelayMode::Custom,
&[relay_a],
)
.unwrap());
}
#[test]
fn persisted_sender_address_preserves_relays_and_accepts_legacy_blob_ticket() {
let secret = SecretKey::generate();
let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap();
let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap();
let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49153));
let addr = iroh::EndpointAddr::new(secret.public())
.with_relay_url(primary.clone())
.with_relay_url(backup)
.with_ip_addr(direct);
let encoded = encode_persisted_sender_address(&addr).unwrap();
assert_eq!(parse_persisted_sender_address(&encoded).unwrap(), addr);
let legacy_addr = iroh::EndpointAddr::new(secret.public())
.with_relay_url(primary)
.with_ip_addr(direct);
let legacy = BlobTicket::new(
legacy_addr.clone(),
Hash::new([12; 32]),
BlobFormat::HashSeq,
)
.to_string();
assert_eq!(
parse_persisted_sender_address(&legacy).unwrap(),
legacy_addr
);
}
#[test]
fn invalid_ticket_is_rejected() {
assert!(parse_transfer_ticket("not-a-ticket").is_err());

View File

@@ -1,27 +1,38 @@
use std::str::FromStr;
use std::{collections::BTreeSet, str::FromStr};
use anyhow::{Context, Result};
use data_encoding::BASE64URL_NOPAD;
use iroh::{EndpointAddr, RelayUrl};
use iroh_blobs::ticket::BlobTicket;
use serde::{Deserialize, Serialize};
use crate::api::{CoreLimits, TransferMetadata};
use crate::api::{CoreLimits, CoreNetworkConfig, CoreRelayMode, TransferMetadata};
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
const VNIDROP_TICKET_VERSION: u8 = 1;
const PERSISTED_SENDER_ADDRESS_PREFIX: &str = "vndaddr1:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VnidropTicket {
version: u8,
blob_ticket: String,
// BlobTicket's current wire format retains only one relay URL. The outer
// envelope carries backups so new receivers can rebuild the full address.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
relay_urls: Vec<String>,
metadata: TransferMetadata,
}
impl VnidropTicket {
pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self {
pub(crate) fn new_with_relay_urls(
blob_ticket: BlobTicket,
metadata: TransferMetadata,
relay_urls: &[RelayUrl],
) -> Self {
Self {
version: VNIDROP_TICKET_VERSION,
blob_ticket: blob_ticket.to_string(),
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
metadata,
}
}
@@ -50,6 +61,35 @@ impl VnidropTicket {
pub(crate) struct ParsedTransferTicket {
pub(crate) blob_ticket: BlobTicket,
pub(crate) metadata: TransferMetadata,
pub(crate) advertised_custom_relay_urls: Vec<RelayUrl>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PersistedSenderAddress {
addr: EndpointAddr,
}
pub(crate) fn encode_persisted_sender_address(addr: &EndpointAddr) -> Result<String> {
let bytes = serde_json::to_vec(&PersistedSenderAddress { addr: addr.clone() })?;
Ok(format!(
"{PERSISTED_SENDER_ADDRESS_PREFIX}{}",
BASE64URL_NOPAD.encode(&bytes)
))
}
pub(crate) fn parse_persisted_sender_address(value: &str) -> Result<EndpointAddr> {
if let Some(encoded) = value.strip_prefix(PERSISTED_SENDER_ADDRESS_PREFIX) {
let bytes = BASE64URL_NOPAD
.decode(encoded.as_bytes())
.context("invalid persisted sender address encoding")?;
let persisted: PersistedSenderAddress =
serde_json::from_slice(&bytes).context("invalid persisted sender address payload")?;
return Ok(persisted.addr);
}
// Rows created before multi-relay invitations stored a raw BlobTicket.
let legacy = BlobTicket::from_str(value).context("invalid legacy sender BlobTicket")?;
Ok(legacy.addr().clone())
}
#[cfg(test)]
@@ -93,17 +133,55 @@ pub(crate) fn parse_transfer_ticket_with_limits(
Some(ticket.metadata.transfer_name.as_str()),
)?;
limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?;
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
let mut blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
.context("invalid BlobTicket inside VniDrop ticket")?;
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
anyhow::bail!("VniDrop ticket metadata hash does not match BlobTicket hash");
}
let advertised_custom_relay_urls = if ticket.relay_urls.is_empty() {
Vec::new()
} else {
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: ticket.relay_urls,
}
.validated_relay_urls()
.context("invalid relay URLs inside VniDrop ticket")?
};
if !advertised_custom_relay_urls.is_empty() {
let (mut addr, hash, format) = blob_ticket.into_parts();
for relay_url in advertised_custom_relay_urls.iter().cloned() {
addr = addr.with_relay_url(relay_url);
}
blob_ticket = BlobTicket::new(addr, hash, format);
}
Ok(ParsedTransferTicket {
blob_ticket,
metadata: ticket.metadata,
advertised_custom_relay_urls,
})
}
pub(crate) fn ticket_matches_relay_profile(
value: &str,
limits: &CoreLimits,
relay_mode: CoreRelayMode,
custom_relay_urls: &[RelayUrl],
) -> Result<bool> {
let parsed = parse_transfer_ticket_with_limits(value, limits)?;
match relay_mode {
CoreRelayMode::Automatic => Ok(parsed.advertised_custom_relay_urls.is_empty()),
CoreRelayMode::Custom => {
let advertised = parsed
.advertised_custom_relay_urls
.into_iter()
.collect::<BTreeSet<_>>();
let configured = custom_relay_urls.iter().cloned().collect::<BTreeSet<_>>();
Ok(advertised == configured)
}
}
}
fn normalize_ticket_input(value: &str) -> String {
// Tickets are commonly copied from text views or chat apps that insert line
// breaks. Strip whitespace only; other corrupt characters should still be

View File

@@ -0,0 +1,106 @@
mod support;
use std::str::FromStr;
use data_encoding::BASE64URL_NOPAD;
use iroh::{EndpointAddr, RelayUrl};
use iroh_blobs::ticket::BlobTicket;
use serde_json::Value;
use support::{receive_with_response, share_path, TestNode, TestRelay};
use vnidrop::{CoreNetworkConfig, CoreRelayMode};
fn custom_config(relay_urls: &[&str]) -> CoreNetworkConfig {
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
}
}
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());
}
let blob_ticket = BlobTicket::new(addr, hash, format);
(value, blob_ticket)
}
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();
let relay_only_addr = EndpointAddr::new(blob_ticket.addr().id).with_relay_url(relay_url);
let relay_only_ticket =
BlobTicket::new(relay_only_addr, blob_ticket.hash(), blob_ticket.format());
value["blob_ticket"] = Value::String(relay_only_ticket.to_string());
let payload = serde_json::to_vec(&value).unwrap();
format!("vnd1:{}", BASE64URL_NOPAD.encode(&payload))
}
#[test]
fn strict_custom_relay_is_advertised_and_transfers_without_direct_ticket_addresses() {
let relay = TestRelay::start();
let backup_relay = "http://127.0.0.1:9";
let relay_urls = [relay.url.as_str(), backup_relay];
let sender = TestNode::with_network_config(custom_config(&relay_urls));
let receiver = TestNode::with_network_config(custom_config(&[relay.url.as_str()]));
let source_dir = tempfile::tempdir().unwrap();
let output_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("custom-relay.txt");
std::fs::write(&source_path, b"through the custom relay").unwrap();
let share = share_path(&sender.core, &source_path, 401, "custom-relay.txt", false);
let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket);
let advertised_relays: Vec<_> = blob_ticket
.addr()
.relay_urls()
.map(ToString::to_string)
.collect();
let configured_relay = RelayUrl::from_str(&relay.url).unwrap().to_string();
let configured_backup = RelayUrl::from_str(backup_relay).unwrap().to_string();
let envelope_relays = ticket_value["relay_urls"]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap().to_string())
.collect::<Vec<_>>();
assert_eq!(
envelope_relays,
vec![configured_relay.clone(), configured_backup.clone()]
);
let mut configured_relays = vec![configured_relay.clone(), configured_backup];
configured_relays.sort();
assert_eq!(advertised_relays, configured_relays.clone());
assert!(!advertised_relays.iter().any(|url| url.contains("n0")));
assert!(!sender.core.status().addr.contains("iroh.link"));
let relay_only_ticket = with_relay_only_address(&share.ticket, &relay.url);
let (_, relay_only_blob_ticket) = read_blob_ticket(&relay_only_ticket);
assert_eq!(relay_only_blob_ticket.addr().ip_addrs().count(), 0);
assert_eq!(
relay_only_blob_ticket
.addr()
.relay_urls()
.map(ToString::to_string)
.collect::<Vec<_>>(),
configured_relays
);
receive_with_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
relay_only_ticket,
output_dir.path(),
true,
)
.unwrap();
assert_eq!(
std::fs::read(output_dir.path().join("custom-relay.txt")).unwrap(),
b"through the custom relay"
);
}

View File

@@ -5,10 +5,10 @@ use std::time::Duration;
use futures_lite::StreamExt as _;
use iroh_blobs::store::fs::FsStore;
use support::{share_path, CoreGuard, RecordingSink, TestNode};
use support::{share_path, CoreGuard, RecordingSink, TestNode, TestRelay};
use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind,
TransferAccessMode,
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, ShareMetadataInput,
ShareSource, SourceKind, TransferAccessMode,
};
#[test]
@@ -137,6 +137,56 @@ fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
assert_eq!(restarted.status().active_shares, 0);
}
#[test]
fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() {
let relay_a = TestRelay::start();
let relay_b = TestRelay::start();
assert_ne!(relay_a.url, relay_b.url);
let source_dir = tempfile::tempdir().unwrap();
let core_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("stale-relay.txt");
std::fs::write(&source_path, b"must not survive a relay profile change").unwrap();
let sender = CoreGuard::start_with_network_config(
core_dir.path(),
Arc::new(RecordingSink::default()),
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: vec![relay_a.url.clone()],
},
);
let share = share_path(&sender, &source_path, 120, "stale-relay.txt", false);
drop(sender);
drop(relay_a);
let restarted = CoreGuard::start_with_network_config(
core_dir.path(),
Arc::new(RecordingSink::default()),
CoreNetworkConfig {
mode: CoreRelayMode::Custom,
relay_urls: vec![relay_b.url.clone()],
},
);
assert_eq!(restarted.status().active_shares, 0);
let transfer = restarted
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == share.transfer_id)
.unwrap();
assert_eq!(transfer.status, "stopped");
assert!(restarted
.list_events(Some(share.transfer_id))
.unwrap()
.iter()
.any(|event| {
event.phase == "recovery" && event.kind == "share-stopped-network-profile-changed"
}));
drop(restarted);
assert_eq!(share_tag_count(core_dir.path()), 0);
}
#[test]
fn stopped_share_rejects_receive() {
let source_dir = tempfile::tempdir().unwrap();

View File

@@ -8,15 +8,16 @@ use std::{
path::Path,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Condvar, Mutex,
mpsc, Arc, Condvar, Mutex,
},
thread::JoinHandle,
time::{Duration, Instant},
};
use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
TransferAccessMode, VnidropCore, VnidropError,
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, PublishedOutput, ReceiveOutputSink,
ReceiveOutputSinkV2, ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult,
ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
};
#[derive(Default)]
@@ -57,6 +58,21 @@ impl CoreGuard {
)
}
pub fn start_with_network_config(
path: &Path,
sink: Arc<dyn CoreEventSink>,
network_config: CoreNetworkConfig,
) -> Self {
Self(
VnidropCore::initialize_with_network_config(
path.to_string_lossy().to_string(),
sink,
network_config,
)
.expect("test core should initialize with network config"),
)
}
pub fn arc(&self) -> Arc<VnidropCore> {
self.0.clone()
}
@@ -93,6 +109,80 @@ impl TestNode {
sink,
}
}
pub fn with_network_config(network_config: CoreNetworkConfig) -> Self {
let data_dir = tempfile::tempdir().unwrap();
let sink = Arc::new(RecordingSink::default());
let core =
CoreGuard::start_with_network_config(data_dir.path(), sink.clone(), network_config);
Self {
_data_dir: data_dir,
core,
sink,
}
}
}
pub struct TestRelay {
pub url: String,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
thread: Option<JoinHandle<()>>,
}
impl TestRelay {
pub fn start() -> Self {
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let thread = std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name("vnidrop-test-relay")
.build()
.unwrap();
runtime.block_on(async move {
let relay =
iroh_relay::server::RelayConfig::new((std::net::Ipv4Addr::LOCALHOST, 0));
let mut config = iroh_relay::server::ServerConfig::default();
config.relay = Some(relay);
let server = match iroh_relay::server::Server::spawn(config).await {
Ok(server) => server,
Err(error) => {
ready_tx.send(Err(error.to_string())).ok();
return;
}
};
let url = format!(
"http://{}",
server.http_addr().expect("HTTP relay should be bound")
);
if ready_tx.send(Ok(url)).is_err() {
return;
}
shutdown_rx.await.ok();
drop(server);
});
});
let url = ready_rx
.recv_timeout(Duration::from_secs(5))
.expect("test relay should start")
.expect("test relay should bind");
Self {
url,
shutdown: Some(shutdown_tx),
thread: Some(thread),
}
}
}
impl Drop for TestRelay {
fn drop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
shutdown.send(()).ok();
}
if let Some(thread) = self.thread.take() {
thread.join().unwrap();
}
}
}
#[derive(Default)]