diff --git a/Cargo.lock b/Cargo.lock index ae9b22e270..91c92d5ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2309,6 +2309,7 @@ dependencies = [ "hashlink", "hex", "indicatif", + "iroh", "iroh-base", "iroh-bytes", "iroh-gossip", diff --git a/iroh-net/Cargo.toml b/iroh-net/Cargo.toml index 417e7e17a3..e5461bd3c1 100644 --- a/iroh-net/Cargo.toml +++ b/iroh-net/Cargo.toml @@ -118,6 +118,7 @@ duct = "0.13.6" default = ["metrics"] iroh-relay = ["clap", "toml", "rustls-pemfile", "regex", "serde_with", "tracing-subscriber"] metrics = ["iroh-metrics/metrics"] +test-utils = [] [[bin]] name = "iroh-relay" diff --git a/iroh-net/src/lib.rs b/iroh-net/src/lib.rs index 5c7dffc803..f1bb046937 100644 --- a/iroh-net/src/lib.rs +++ b/iroh-net/src/lib.rs @@ -35,5 +35,5 @@ pub use iroh_base::key; pub use iroh_base::key::NodeId; -#[cfg(test)] -pub(crate) mod test_utils; +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; diff --git a/iroh-net/src/magic_endpoint.rs b/iroh-net/src/magic_endpoint.rs index 318ce5da92..19837cd4e3 100644 --- a/iroh-net/src/magic_endpoint.rs +++ b/iroh-net/src/magic_endpoint.rs @@ -41,6 +41,8 @@ pub struct MagicEndpointBuilder { /// Path for known peers. See [`MagicEndpointBuilder::peers_data_path`]. peers_path: Option, dns_resolver: Option, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: bool, } impl Default for MagicEndpointBuilder { @@ -55,6 +57,8 @@ impl Default for MagicEndpointBuilder { discovery: Default::default(), peers_path: None, dns_resolver: None, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: false, } } } @@ -84,6 +88,15 @@ impl MagicEndpointBuilder { self } + /// Skip verification of SSL certificates from relay servers + /// + /// May only be used in tests. + #[cfg(any(test, feature = "test-utils"))] + pub fn insecure_skip_relay_cert_verify(mut self, skip_verify: bool) -> Self { + self.insecure_skip_relay_cert_verify = skip_verify; + self + } + /// Sets the relay servers to assist in establishing connectivity. /// /// relay servers are used to discover other peers by [`PublicKey`] and also help @@ -192,6 +205,8 @@ impl MagicEndpointBuilder { nodes_path: self.peers_path, discovery: self.discovery, dns_resolver, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: self.insecure_skip_relay_cert_verify, }; MagicEndpoint::bind(Some(server_config), msock_opts, self.keylog).await } @@ -670,6 +685,7 @@ mod tests { .secret_key(server_secret_key) .alpns(vec![TEST_ALPN.to_vec()]) .relay_mode(RelayMode::Custom(relay_map)) + .insecure_skip_relay_cert_verify(true) .bind(0) .await .unwrap(); @@ -704,6 +720,7 @@ mod tests { let ep = MagicEndpoint::builder() .alpns(vec![TEST_ALPN.to_vec()]) .relay_mode(RelayMode::Custom(relay_map)) + .insecure_skip_relay_cert_verify(true) .bind(0) .await .unwrap(); @@ -813,6 +830,7 @@ mod tests { tokio::spawn( async move { let ep = MagicEndpoint::builder() + .insecure_skip_relay_cert_verify(true) .secret_key(server_secret_key) .alpns(vec![TEST_ALPN.to_vec()]) .relay_mode(RelayMode::Custom(relay_map)) @@ -857,6 +875,7 @@ mod tests { info!("client binding"); let ep = MagicEndpoint::builder() .alpns(vec![TEST_ALPN.to_vec()]) + .insecure_skip_relay_cert_verify(true) .relay_mode(RelayMode::Custom(relay_map)) .secret_key(client_secret_key) .bind(0) diff --git a/iroh-net/src/magicsock.rs b/iroh-net/src/magicsock.rs index 43e9d0c257..b243cc1a5a 100644 --- a/iroh-net/src/magicsock.rs +++ b/iroh-net/src/magicsock.rs @@ -119,6 +119,12 @@ pub struct Options { /// You can use [`crate::dns::default_resolver`] for a resolver that uses the system's DNS /// configuration. pub dns_resolver: DnsResolver, + + /// Skip verification of SSL certificates from relay servers + /// + /// May only be used in tests. + #[cfg(any(test, feature = "test-utils"))] + pub insecure_skip_relay_cert_verify: bool, } impl Default for Options { @@ -130,6 +136,8 @@ impl Default for Options { nodes_path: None, discovery: None, dns_resolver: crate::dns::default_resolver().clone(), + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: false, } } } @@ -220,6 +228,12 @@ struct Inner { /// Indicates the update endpoint state. endpoints_update_state: EndpointUpdateState, + + /// Skip verification of SSL certificates from relay servers + /// + /// May only be used in tests. + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: bool, } impl Inner { @@ -1150,6 +1164,8 @@ impl MagicSock { discovery, nodes_path, dns_resolver, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify, } = opts; let nodes_path = match nodes_path { @@ -1230,6 +1246,8 @@ impl MagicSock { pending_call_me_maybes: Default::default(), endpoints_update_state: EndpointUpdateState::new(), dns_resolver, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify, }); let mut actor_tasks = JoinSet::default(); diff --git a/iroh-net/src/magicsock/relay_actor.rs b/iroh-net/src/magicsock/relay_actor.rs index f40daf2de8..cf97db5419 100644 --- a/iroh-net/src/magicsock/relay_actor.rs +++ b/iroh-net/src/magicsock/relay_actor.rs @@ -479,14 +479,19 @@ impl RelayActor { let url1 = url.clone(); // building a client dials the relay - let (dc, dc_receiver) = relay::http::ClientBuilder::new(url1.clone()) + let builder = relay::http::ClientBuilder::new(url1.clone()) .address_family_selector(move || { let ipv6_reported = ipv6_reported.clone(); Box::pin(async move { ipv6_reported.load(Ordering::Relaxed) }) }) .can_ack_pings(true) - .is_preferred(my_relay.as_ref() == Some(&url1)) - .build(self.conn.secret_key.clone(), self.conn.dns_resolver.clone()); + .is_preferred(my_relay.as_ref() == Some(&url1)); + + #[cfg(any(test, feature = "test-utils"))] + let builder = builder.insecure_skip_cert_verify(self.conn.insecure_skip_relay_cert_verify); + + let (dc, dc_receiver) = + builder.build(self.conn.secret_key.clone(), self.conn.dns_resolver.clone()); let (s, r) = mpsc::channel(64); diff --git a/iroh-net/src/relay/http.rs b/iroh-net/src/relay/http.rs index 120722a068..5ea0aee188 100644 --- a/iroh-net/src/relay/http.rs +++ b/iroh-net/src/relay/http.rs @@ -9,7 +9,7 @@ pub use self::server::{Server, ServerBuilder, TlsAcceptor, TlsConfig}; pub(crate) const HTTP_UPGRADE_PROTOCOL: &str = "iroh derp http"; -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] pub(crate) fn make_tls_config() -> TlsConfig { let subject_alt_names = vec!["localhost".to_string()]; @@ -128,7 +128,7 @@ mod tests { JoinHandle<()>, Client, ) { - let client = ClientBuilder::new(server_url); + let client = ClientBuilder::new(server_url).insecure_skip_cert_verify(true); let dns_resolver = crate::dns::default_resolver(); let (client, mut client_reader) = client.build(key.clone(), dns_resolver.clone()); let public_key = key.public(); diff --git a/iroh-net/src/relay/http/client.rs b/iroh-net/src/relay/http/client.rs index 7c92b69850..d399959401 100644 --- a/iroh-net/src/relay/http/client.rs +++ b/iroh-net/src/relay/http/client.rs @@ -201,6 +201,9 @@ pub struct ClientBuilder { server_public_key: Option, /// Server url. url: RelayUrl, + /// Allow self-signed certificates from relay servers + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_cert_verify: bool, } impl std::fmt::Debug for ClientBuilder { @@ -223,6 +226,8 @@ impl ClientBuilder { is_prober: false, server_public_key: None, url: url.into(), + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_cert_verify: false, } } @@ -265,6 +270,15 @@ impl ClientBuilder { self } + /// Skip the verification of the relay server's SSL certificates. + /// + /// May only be used in tests. + #[cfg(any(test, feature = "test-utils"))] + pub fn insecure_skip_cert_verify(mut self, skip: bool) -> Self { + self.insecure_skip_cert_verify = skip; + self + } + /// Build the [`Client`] pub fn build(self, key: SecretKey, dns_resolver: DnsResolver) -> (Client, ClientReceiver) { // TODO: review TLS config @@ -280,10 +294,13 @@ impl ClientBuilder { .with_safe_defaults() .with_root_certificates(roots) .with_no_client_auth(); - #[cfg(test)] - config - .dangerous() - .set_certificate_verifier(Arc::new(NoCertVerifier)); + #[cfg(any(test, feature = "test-utils"))] + if self.insecure_skip_cert_verify { + warn!("Insecure config: SSL certificates from relay servers will be trusted without verification"); + config + .dangerous() + .set_certificate_verifier(Arc::new(NoCertVerifier)); + } config.resumption = Resumption::default(); @@ -904,10 +921,10 @@ fn downcast_upgrade( } /// Used to allow self signed certificates in tests -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] struct NoCertVerifier; -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl rustls::client::ServerCertVerifier for NoCertVerifier { fn verify_server_cert( &self, diff --git a/iroh-net/src/stun.rs b/iroh-net/src/stun.rs index d6dbe1f3c2..3df7fd92e5 100644 --- a/iroh-net/src/stun.rs +++ b/iroh-net/src/stun.rs @@ -149,19 +149,13 @@ pub fn parse_response(b: &[u8]) -> Result<(TransactionId, SocketAddr), Error> { Err(Error::MalformedAttrs) } -#[cfg(test)] -pub mod test { +#[cfg(any(test, feature = "test-utils"))] +pub(crate) mod test { use std::{ - net::{IpAddr, Ipv4Addr}, + net::{IpAddr, SocketAddr}, sync::Arc, }; - use crate::{ - relay::{RelayMap, RelayNode, RelayUrl}, - test_utils::CleanupDropGuard, - }; - - use super::*; use anyhow::Result; use tokio::{ net, @@ -169,21 +163,30 @@ pub mod test { }; use tracing::{debug, trace}; + #[cfg(test)] + use crate::relay::{RelayMap, RelayNode, RelayUrl}; + use crate::test_utils::CleanupDropGuard; + + use super::*; + // (read_ipv4, read_ipv5) #[derive(Debug, Default, Clone)] pub struct StunStats(Arc>); impl StunStats { + #[cfg(test)] pub async fn total(&self) -> usize { let s = self.0.lock().await; s.0 + s.1 } } + #[cfg(test)] pub fn relay_map_of(stun: impl Iterator) -> RelayMap { relay_map_of_opts(stun.map(|addr| (addr, true))) } + #[cfg(test)] pub fn relay_map_of_opts(stun: impl Iterator) -> RelayMap { let nodes = stun.map(|(addr, stun_only)| { let host = addr.ip(); @@ -202,8 +205,9 @@ pub mod test { /// Sets up a simple STUN server binding to `0.0.0.0:0`. /// /// See [`serve`] for more details. + #[cfg(test)] pub(crate) async fn serve_v4() -> Result<(SocketAddr, StunStats, CleanupDropGuard)> { - serve(Ipv4Addr::UNSPECIFIED.into()).await + serve(std::net::Ipv4Addr::UNSPECIFIED.into()).await } /// Sets up a simple STUN server. diff --git a/iroh-net/src/test_utils.rs b/iroh-net/src/test_utils.rs index 6292349a2b..7a866d1f3e 100644 --- a/iroh-net/src/test_utils.rs +++ b/iroh-net/src/test_utils.rs @@ -15,7 +15,7 @@ use crate::relay::{RelayMap, RelayNode, RelayUrl}; // sender. #[derive(Debug)] #[allow(dead_code)] -pub(crate) struct CleanupDropGuard(pub(crate) oneshot::Sender<()>); +pub struct CleanupDropGuard(pub(crate) oneshot::Sender<()>); /// Runs a relay server with STUN enabled suitable for tests. /// @@ -23,7 +23,7 @@ pub(crate) struct CleanupDropGuard(pub(crate) oneshot::Sender<()>); /// is always `Some` as that is how the [`MagicEndpoint::connect`] API expects it. /// /// [`MagicEndpoint::connect`]: crate::magic_endpoint::MagicEndpoint -pub(crate) async fn run_relay_server() -> Result<(RelayMap, RelayUrl, CleanupDropGuard)> { +pub async fn run_relay_server() -> Result<(RelayMap, RelayUrl, CleanupDropGuard)> { let server_key = SecretKey::generate(); let me = server_key.public().fmt_short(); let tls_config = crate::relay::http::make_tls_config(); diff --git a/iroh/Cargo.toml b/iroh/Cargo.toml index 561805989e..af65ecd65c 100644 --- a/iroh/Cargo.toml +++ b/iroh/Cargo.toml @@ -62,12 +62,14 @@ metrics = ["iroh-metrics", "iroh-bytes/metrics"] fs-store = ["iroh-bytes/fs-store"] test = [] examples = ["dep:clap", "dep:indicatif"] +test-utils = ["iroh-net/test-utils"] [dev-dependencies] anyhow = { version = "1" } bytes = "1" console-subscriber = "0.2" genawaiter = { version = "0.99", features = ["futures03"] } +iroh = { path = ".", features = ["test-utils"] } iroh-test = { path = "../iroh-test" } proptest = "1.2.0" rand_chacha = "0.3.1" diff --git a/iroh/src/node.rs b/iroh/src/node.rs index 79b1015469..e8a96d2fc3 100644 --- a/iroh/src/node.rs +++ b/iroh/src/node.rs @@ -284,8 +284,14 @@ mod tests { use anyhow::{bail, Context}; use bytes::Bytes; use iroh_bytes::provider::AddProgress; + use iroh_net::relay::RelayMode; - use crate::rpc_protocol::{BlobAddPathRequest, BlobAddPathResponse, SetTagOption, WrapOption}; + use crate::{ + client::BlobAddOutcome, + rpc_protocol::{ + BlobAddPathRequest, BlobAddPathResponse, BlobDownloadRequest, SetTagOption, WrapOption, + }, + }; use super::*; @@ -405,4 +411,44 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_download_via_relay() -> Result<()> { + let _guard = iroh_test::logging::setup(); + let (relay_map, relay_url, _guard) = iroh_net::test_utils::run_relay_server().await?; + + let node1 = Node::memory() + .bind_port(0) + .relay_mode(RelayMode::Custom(relay_map.clone())) + .insecure_skip_relay_cert_verify(true) + .spawn() + .await?; + let node2 = Node::memory() + .bind_port(0) + .relay_mode(RelayMode::Custom(relay_map.clone())) + .insecure_skip_relay_cert_verify(true) + .spawn() + .await?; + let BlobAddOutcome { hash, .. } = node1.blobs.add_bytes(b"foo".to_vec()).await?; + + // create a node addr with only a relay URL, no direct addresses + let addr = NodeAddr::new(node1.node_id()).with_relay_url(relay_url); + let req = BlobDownloadRequest { + hash, + tag: SetTagOption::Auto, + format: BlobFormat::Raw, + peer: addr, + }; + node2.blobs.download(req).await?.await?; + assert_eq!( + node2 + .blobs + .read_to_bytes(hash) + .await + .context("get")? + .as_ref(), + b"foo" + ); + Ok(()) + } } diff --git a/iroh/src/node/builder.rs b/iroh/src/node/builder.rs index 93d6877cd8..6418cfb08c 100644 --- a/iroh/src/node/builder.rs +++ b/iroh/src/node/builder.rs @@ -81,6 +81,8 @@ where relay_mode: RelayMode, gc_policy: GcPolicy, docs_store: iroh_sync::store::fs::Store, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: bool, } /// Configuration for storage. @@ -104,6 +106,8 @@ impl Default for Builder { rpc_endpoint: Default::default(), gc_policy: GcPolicy::Disabled, docs_store: iroh_sync::store::Store::memory(), + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: false, } } } @@ -125,6 +129,8 @@ impl Builder { rpc_endpoint: Default::default(), gc_policy: GcPolicy::Disabled, docs_store, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: false, } } } @@ -183,6 +189,8 @@ where relay_mode: self.relay_mode, gc_policy: self.gc_policy, docs_store, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: false, }) } @@ -199,6 +207,8 @@ where relay_mode: self.relay_mode, gc_policy: self.gc_policy, docs_store: self.docs_store, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: self.insecure_skip_relay_cert_verify, } } @@ -222,6 +232,8 @@ where relay_mode: self.relay_mode, gc_policy: self.gc_policy, docs_store: self.docs_store, + #[cfg(any(test, feature = "test-utils"))] + insecure_skip_relay_cert_verify: self.insecure_skip_relay_cert_verify, }) } @@ -261,6 +273,15 @@ where self } + /// Skip verification of SSL certificates from relay servers + /// + /// May only be used in tests. + #[cfg(any(test, feature = "test-utils"))] + pub fn insecure_skip_relay_cert_verify(mut self, skip_verify: bool) -> Self { + self.insecure_skip_relay_cert_verify = skip_verify; + self + } + /// Whether to log the SSL pre-master key. /// /// If `true` and the `SSLKEYLOGFILE` environment variable is the path to a file this @@ -292,6 +313,11 @@ where .transport_config(transport_config) .concurrent_connections(MAX_CONNECTIONS) .relay_mode(self.relay_mode); + + #[cfg(any(test, feature = "test-utils"))] + let endpoint = + endpoint.insecure_skip_relay_cert_verify(self.insecure_skip_relay_cert_verify); + let endpoint = match self.storage { StorageConfig::Persistent(ref root) => { let peers_data_path = IrohPaths::PeerData.with_root(root); diff --git a/iroh/tests/sync.rs b/iroh/tests/sync.rs index c7096aaa0a..d2161932d4 100644 --- a/iroh/tests/sync.rs +++ b/iroh/tests/sync.rs @@ -458,6 +458,90 @@ async fn sync_subscribe_stop_close() -> Result<()> { Ok(()) } +#[tokio::test] +#[cfg(feature = "test-utils")] +async fn test_sync_via_relay() -> Result<()> { + let _guard = iroh_test::logging::setup(); + let (relay_map, _relay_url, _guard) = iroh_net::test_utils::run_relay_server().await?; + + let node1 = Node::memory() + .bind_port(0) + .relay_mode(RelayMode::Custom(relay_map.clone())) + .insecure_skip_relay_cert_verify(true) + .spawn() + .await?; + let node1_id = node1.node_id(); + let node2 = Node::memory() + .bind_port(0) + .relay_mode(RelayMode::Custom(relay_map.clone())) + .insecure_skip_relay_cert_verify(true) + .spawn() + .await?; + + let doc1 = node1.docs.create().await?; + let author1 = node1.authors.create().await?; + let inserted_hash = doc1 + .set_bytes(author1, b"foo".to_vec(), b"bar".to_vec()) + .await?; + let mut ticket = doc1.share(ShareMode::Write).await?; + + // remove direct addrs to force connect via relay + ticket.nodes[0].info.direct_addresses = Default::default(); + + // join + let doc2 = node2.docs.import(ticket).await?; + let mut events = doc2.subscribe().await?; + + assert_next_unordered_with_optionals( + &mut events, + Duration::from_secs(2), + vec![ + Box::new(move |e| matches!(e, LiveEvent::NeighborUp(n) if *n== node1_id)), + Box::new(move |e| match_sync_finished(e, node1_id)), + Box::new( + move |e| matches!(e, LiveEvent::InsertRemote { from, content_status: ContentStatus::Missing, .. } if *from == node1_id), + ), + Box::new( + move |e| matches!(e, LiveEvent::ContentReady { hash } if *hash == inserted_hash), + ), + ], + vec![Box::new(move |e| match_sync_finished(e, node1_id))], + ).await; + let actual = doc2 + .get_exact(author1, b"foo", false) + .await? + .expect("entry to exist") + .content_bytes(&doc2) + .await?; + assert_eq!(actual.as_ref(), b"bar"); + + // update + let updated_hash = doc1 + .set_bytes(author1, b"foo".to_vec(), b"update".to_vec()) + .await?; + assert_next_unordered_with_optionals( + &mut events, + Duration::from_secs(2), + vec![ + Box::new( + move |e| matches!(e, LiveEvent::InsertRemote { from, content_status: ContentStatus::Missing, .. } if *from == node1_id), + ), + Box::new( + move |e| matches!(e, LiveEvent::ContentReady { hash } if *hash == updated_hash), + ), + ], + vec![Box::new(move |e| match_sync_finished(e, node1_id))], + ).await; + let actual = doc2 + .get_exact(author1, b"foo", false) + .await? + .expect("entry to exist") + .content_bytes(&doc2) + .await?; + assert_eq!(actual.as_ref(), b"update"); + Ok(()) +} + /// Joins two nodes that write to the same document but have differing download policies and tests /// that they both synced the key info but not the content. #[tokio::test] @@ -1045,5 +1129,5 @@ fn match_sync_finished(event: &LiveEvent, peer: PublicKey) -> bool { let LiveEvent::SyncFinished(e) = event else { return false; }; - e.peer == peer && e.result == Ok(()) + e.peer == peer && e.result.is_ok() }