mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
186
crates/vnidrop/src/tests/network_config.rs
Normal file
186
crates/vnidrop/src/tests/network_config.rs
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
106
crates/vnidrop/tests/custom_relay.rs
Normal file
106
crates/vnidrop/tests/custom_relay.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user