diff --git a/Cargo.lock b/Cargo.lock index 57fab39d6dd..7e4fa1d89b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2246,11 +2246,14 @@ dependencies = [ "rcgen", "redb", "regex", + "reqwest", "rustls", "rustls-pemfile", "serde", + "serde_json", "struct_iterable", "strum", + "tempfile", "tokio", "tokio-rustls", "tokio-rustls-acme", @@ -3583,6 +3586,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", + "h2", "http 1.4.0", "http-body", "http-body-util", diff --git a/iroh-dns-server/Cargo.toml b/iroh-dns-server/Cargo.toml index 951382138b2..fb2b28e377d 100644 --- a/iroh-dns-server/Cargo.toml +++ b/iroh-dns-server/Cargo.toml @@ -65,6 +65,11 @@ hickory-resolver = "0.25.0" iroh = { path = "../iroh" } rand = "0.9.2" rand_chacha = "0.9" +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", "http2" +] } +serde_json = "1.0.145" +tempfile = "3.23.0" tracing-test = "0.2.5" [[bench]] diff --git a/iroh-dns-server/benches/write.rs b/iroh-dns-server/benches/write.rs index 9373c1bfb95..47e6c47b424 100644 --- a/iroh-dns-server/benches/write.rs +++ b/iroh-dns-server/benches/write.rs @@ -12,7 +12,7 @@ const LOCALHOST_PKARR: &str = "http://localhost:8080/pkarr"; async fn start_dns_server(config: Config) -> Result { let metrics = Arc::new(Metrics::default()); let store = ZoneStore::persistent( - Config::signed_packet_store_path()?, + config.signed_packet_store_path()?, Default::default(), metrics.clone(), )?; diff --git a/iroh-dns-server/src/config.rs b/iroh-dns-server/src/config.rs index 9f602990099..32d6e7c8c73 100644 --- a/iroh-dns-server/src/config.rs +++ b/iroh-dns-server/src/config.rs @@ -52,6 +52,12 @@ pub struct Config { /// Config for pkarr rate limit #[serde(default)] pub pkarr_put_rate_limit: RateLimitConfig, + + /// Location where all data of iroh-dns-server is stored. + /// + /// If unset, will use `IROH_DNS_DATA_DIR` environment variable if set, + /// and otherwise a `iroh-dns` directory in the host system's data directory. + pub data_dir: Option, } /// The config for the store. @@ -168,8 +174,10 @@ impl Config { } /// Get the data directory. - pub fn data_dir() -> Result { - let dir = if let Some(val) = env::var_os("IROH_DNS_DATA_DIR") { + pub fn data_dir(&self) -> Result { + let dir = if let Some(dir) = &self.data_dir { + dir.clone() + } else if let Some(val) = env::var_os("IROH_DNS_DATA_DIR") { PathBuf::from(val) } else { let path = dirs_next::data_dir() @@ -181,8 +189,8 @@ impl Config { } /// Get the path to the store database file. - pub fn signed_packet_store_path() -> Result { - Ok(Self::data_dir()?.join("signed-packets-1.db")) + pub fn signed_packet_store_path(&self) -> Result { + Ok(self.data_dir()?.join("signed-packets-1.db")) } /// Get the address where the metrics server should be bound, if set. @@ -243,6 +251,7 @@ impl Default for Config { metrics: None, mainline: None, pkarr_put_rate_limit: RateLimitConfig::default(), + data_dir: None, } } } diff --git a/iroh-dns-server/src/http.rs b/iroh-dns-server/src/http.rs index cc993ffcb24..50be38a682a 100644 --- a/iroh-dns-server/src/http.rs +++ b/iroh-dns-server/src/http.rs @@ -2,6 +2,7 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, + path::PathBuf, time::Instant, }; @@ -30,7 +31,7 @@ mod rate_limiting; mod tls; pub use self::{rate_limiting::RateLimitConfig, tls::CertMode}; -use crate::{config::Config, state::AppState}; +use crate::state::AppState; /// Config for the HTTP server #[derive(Debug, Serialize, Deserialize, Clone)] @@ -72,6 +73,7 @@ impl HttpServer { https_config: Option, rate_limit_config: RateLimitConfig, state: AppState, + cert_cache_dir: PathBuf, ) -> Result { if http_config.is_none() && https_config.is_none() { bail_any!("Either http or https config is required"); @@ -110,19 +112,19 @@ impl HttpServer { config.port, ); let acceptor = { - let cache_path = Config::data_dir()? - .join("cert_cache") - .join(config.cert_mode.to_string()); - tokio::fs::create_dir_all(&cache_path) + tokio::fs::create_dir_all(&cert_cache_dir) .await .with_std_context(|_| { - format!("failed to create cert cache dir at {cache_path:?}") + format!( + "failed to create cert cache dir at {}", + cert_cache_dir.display() + ) })?; config .cert_mode .build( config.domains, - cache_path, + cert_cache_dir, config.letsencrypt_contact, config.letsencrypt_prod.unwrap_or(false), ) @@ -272,3 +274,222 @@ async fn metrics_middleware( } response } + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use hickory_resolver::{ + config::{NameServerConfig, ResolverConfig, ResolverOpts}, + name_server::TokioConnectionProvider, + }; + use hickory_server::proto::rr::RecordType; + use iroh::{ + RelayUrl, SecretKey, + discovery::{EndpointInfo, pkarr::PkarrRelayClient}, + endpoint_info::EndpointIdExt, + }; + use n0_error::StdResultExt; + use rand::SeedableRng; + use tracing_test::traced_test; + + use crate::{http::HttpsConfig, server::Server}; + + #[tokio::test] + #[traced_test] + async fn test_doh() -> n0_error::Result { + let mut rng = rand_chacha::ChaCha12Rng::seed_from_u64(0); + let dir = tempfile::tempdir()?; + let https_config = HttpsConfig { + port: 0, + bind_addr: Some(Ipv4Addr::LOCALHOST.into()), + domains: vec!["localhost".to_string()], + cert_mode: crate::http::CertMode::SelfSigned, + letsencrypt_contact: None, + letsencrypt_prod: None, + }; + let server = + Server::spawn_for_tests_with_options(dir.path(), None, None, Some(https_config)) + .await?; + + let (name_z32, signed_packet) = { + let secret_key = SecretKey::generate(&mut rng); + let endpoint_id = secret_key.public(); + let relay_url: RelayUrl = "https://relay.example.".parse()?; + let endpoint_info = + EndpointInfo::new(endpoint_id).with_relay_url(Some(relay_url.clone())); + ( + secret_key.public().to_z32(), + endpoint_info.to_pkarr_signed_packet(&secret_key, 30)?, + ) + }; + + let http_url = server.http_url().expect("http is bound"); + let pkarr = PkarrRelayClient::new(format!("{http_url}pkarr").parse().anyerr()?); + pkarr.publish(&signed_packet).await?; + + // Create a reqwest client that does not verify certificates. + let client = reqwest::Client::builder() + .http2_prior_knowledge() + .use_preconfigured_tls(self::tls::insecure_tls_config()) + .build() + .anyerr()?; + + // Fetch as JSON via HTTP. + let url = format!( + "{http_url}dns-query?name={}&type=txt", + format_args!("_iroh.{name_z32}."), + ); + let res = client + .get(url) + .header("accept", "application/dns-json") + .send() + .await + .anyerr()? + .json::() + .await + .anyerr()?; + assert_eq!(res.answer.len(), 1); + assert_eq!(res.answer[0].name, format!("_iroh.{name_z32}.")); + assert_eq!(res.answer[0].data, "relay=https://relay.example./"); + + // Fetch as JSON via HTTPS. + let https_url = server.https_url().expect("https is bound"); + let url = format!( + "{https_url}dns-query?name={}&type=txt", + format_args!("_iroh.{name_z32}."), + ); + let res = client + .get(url) + .header("accept", "application/dns-json") + .send() + .await + .anyerr()? + .json::() + .await + .anyerr()?; + assert_eq!(res.answer.len(), 1); + assert_eq!(res.answer[0].name, format!("_iroh.{name_z32}.")); + assert_eq!(res.answer[0].data, "relay=https://relay.example./"); + + // Fetch over HTTPS via hickory-resolver + let client = { + let config = { + let mut config = ResolverConfig::new(); + let mut name_server = NameServerConfig::new( + server.https_addr().expect("https is bound"), + hickory_server::proto::xfer::Protocol::Https, + ); + name_server.tls_dns_name = Some("localhost".to_string()); + config.add_name_server(name_server); + config + }; + + let opts = { + let mut opts = ResolverOpts::default(); + opts.tls_config = self::tls::insecure_tls_config(); + opts + }; + + hickory_resolver::Resolver::builder_with_config( + config, + TokioConnectionProvider::default(), + ) + .with_options(opts) + .build() + }; + + let res = client + .txt_lookup(format!("_iroh.{name_z32}.")) + .await + .anyerr()?; + let records = res.as_lookup().records(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].record_type(), RecordType::TXT); + let txt_data = records[0].data().as_txt().unwrap().txt_data(); + assert_eq!(&txt_data[0][..], b"relay=https://relay.example./"); + + server.shutdown().await?; + Ok(()) + } + + mod tls { + use std::sync::Arc; + + use rustls::{ + DigitallySignedStruct, RootCertStore, + client::{ + ClientConfig, + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + }, + crypto::{ + CryptoProvider, ring::default_provider, verify_tls12_signature, + verify_tls13_signature, + }, + pki_types::{CertificateDer, ServerName, UnixTime}, + }; + + #[derive(Debug)] + struct NoCertificateVerification(CryptoProvider); + + impl Default for NoCertificateVerification { + fn default() -> Self { + Self(default_provider()) + } + } + + impl ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls12_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls13_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } + } + + pub(super) fn insecure_tls_config() -> ClientConfig { + let mut cfg = ClientConfig::builder() + .with_root_certificates(RootCertStore::empty()) + .with_no_client_auth(); + cfg.dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerification::default())); + cfg + } + } +} diff --git a/iroh-dns-server/src/http/doh.rs b/iroh-dns-server/src/http/doh.rs index 984d372853e..99454f94a50 100644 --- a/iroh-dns-server/src/http/doh.rs +++ b/iroh-dns-server/src/http/doh.rs @@ -25,6 +25,8 @@ mod extract; mod response; use self::extract::{DnsMimeType, DnsRequestBody, DnsRequestQuery}; +#[cfg(test)] +pub(crate) use self::response::DnsResponse; /// GET handler for resolving DoH queries pub async fn get( diff --git a/iroh-dns-server/src/lib.rs b/iroh-dns-server/src/lib.rs index c3f7475a674..7d02b88f204 100644 --- a/iroh-dns-server/src/lib.rs +++ b/iroh-dns-server/src/lib.rs @@ -43,9 +43,10 @@ mod tests { #[tokio::test] #[traced_test] async fn pkarr_publish_dns_resolve() -> Result { - let (server, nameserver, http_url) = Server::spawn_for_tests().await?; + let dir = tempfile::tempdir()?; + let server = Server::spawn_for_tests(dir.path()).await?; let pkarr_relay_url = { - let mut url = http_url.clone(); + let mut url = server.http_url().expect("http is bound"); url.set_path("/pkarr"); url }; @@ -113,7 +114,7 @@ mod tests { use hickory_server::proto::rr::Name; let pubkey = signed_packet.public_key().to_z32(); - let resolver = test_resolver(nameserver); + let resolver = test_resolver(server.dns_addr()); // resolve root record let name = Name::from_utf8(format!("{pubkey}.")).anyerr()?; @@ -158,10 +159,11 @@ mod tests { #[tokio::test] #[traced_test] async fn integration_smoke() -> Result { - let (server, nameserver, http_url) = Server::spawn_for_tests().await?; + let dir = tempfile::tempdir()?; + let server = Server::spawn_for_tests(dir.path()).await?; let pkarr_relay = { - let mut url = http_url.clone(); + let mut url = server.http_url().expect("http is bound"); url.set_path("/pkarr"); url }; @@ -179,7 +181,7 @@ mod tests { pkarr.publish(&signed_packet).await?; - let resolver = test_resolver(nameserver); + let resolver = test_resolver(server.dns_addr()); let res = resolver.lookup_endpoint_by_id(&endpoint_id, origin).await?; assert_eq!(res.endpoint_id, endpoint_id); @@ -225,6 +227,7 @@ mod tests { #[traced_test] #[ignore = "flaky"] async fn integration_mainline() -> Result { + let dir = tempfile::tempdir()?; let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0u64); // run a mainline testnet @@ -232,9 +235,13 @@ mod tests { let bootstrap = testnet.bootstrap.clone(); // spawn our server with mainline support - let (server, nameserver, _http_url) = - Server::spawn_for_tests_with_options(Some(BootstrapOption::Custom(bootstrap)), None) - .await?; + let server = Server::spawn_for_tests_with_options( + dir.path(), + Some(BootstrapOption::Custom(bootstrap)), + None, + None, + ) + .await?; let origin = "irohdns.example."; @@ -254,7 +261,7 @@ mod tests { pkarr.publish(&signed_packet, None).await.anyerr()?; // resolve via DNS from our server, which will lookup from our DHT - let resolver = test_resolver(nameserver); + let resolver = test_resolver(server.dns_addr()); let res = resolver.lookup_endpoint_by_id(&endpoint_id, origin).await?; assert_eq!(res.endpoint_id, endpoint_id); diff --git a/iroh-dns-server/src/server.rs b/iroh-dns-server/src/server.rs index 361e1899b8f..f6861bea308 100644 --- a/iroh-dns-server/src/server.rs +++ b/iroh-dns-server/src/server.rs @@ -1,10 +1,16 @@ //! The main server which combines the DNS and HTTP(S) servers. use std::sync::Arc; +#[cfg(test)] +use std::{net::SocketAddr, path::Path}; use iroh_metrics::service::start_metrics_server; use n0_error::{Result, StdResultExt}; use tracing::info; +#[cfg(test)] +use url::Url; +#[cfg(test)] +use crate::http::HttpsConfig; use crate::{ config::Config, dns::{DnsHandler, DnsServer}, @@ -19,7 +25,7 @@ pub async fn run_with_config_until_ctrl_c(config: Config) -> Result<()> { let metrics = Arc::new(Metrics::default()); let zone_store_options = config.zone_store.clone().unwrap_or_default(); let mut store = ZoneStore::persistent( - Config::signed_packet_store_path()?, + config.signed_packet_store_path()?, zone_store_options.into(), metrics.clone(), )?; @@ -49,6 +55,7 @@ impl Server { /// * A HTTP server task, if `config.http` is not empty /// * A HTTPS server task, if `config.https` is not empty pub async fn spawn(config: Config, store: ZoneStore, metrics: Arc) -> Result { + let cert_cache_dir = config.data_dir()?.join("cert_cache"); let dns_handler = DnsHandler::new(store.clone(), &config.dns, metrics.clone())?; let state = AppState { @@ -73,6 +80,7 @@ impl Server { config.https, config.pkarr_put_rate_limit, state.clone(), + cert_cache_dir, ) .await?; let dns_server = DnsServer::spawn(config.dns, state.dns_handler.clone()).await?; @@ -111,17 +119,19 @@ impl Server { /// It returns the server handle, the [`SocketAddr`] of the DNS server and the [`Url`] of the /// HTTP server. #[cfg(test)] - pub async fn spawn_for_tests() -> Result<(Self, std::net::SocketAddr, url::Url)> { - Self::spawn_for_tests_with_options(None, None).await + pub async fn spawn_for_tests(dir: impl AsRef) -> Result { + Self::spawn_for_tests_with_options(dir, None, None, None).await } /// Spawn a server suitable for testing, while optionally enabling mainline with custom /// bootstrap addresses. #[cfg(test)] pub async fn spawn_for_tests_with_options( + dir: impl AsRef, mainline: Option, options: Option, - ) -> Result<(Self, std::net::SocketAddr, url::Url)> { + https: Option, + ) -> Result { use std::net::{IpAddr, Ipv4Addr}; use crate::config::MetricsConfig; @@ -131,8 +141,9 @@ impl Server { config.dns.bind_addr = Some(IpAddr::V4(Ipv4Addr::LOCALHOST)); config.http.as_mut().unwrap().port = 0; config.http.as_mut().unwrap().bind_addr = Some(IpAddr::V4(Ipv4Addr::LOCALHOST)); - config.https = None; + config.https = https; config.metrics = Some(MetricsConfig::disabled()); + config.data_dir = Some(dir.as_ref().to_owned()); let mut store = ZoneStore::in_memory(options.unwrap_or_default(), Default::default())?; if let Some(bootstrap) = mainline { @@ -140,9 +151,36 @@ impl Server { store = store.with_mainline_fallback(bootstrap); } let server = Self::spawn(config, store, Default::default()).await?; - let dns_addr = server.dns_server.local_addr(); - let http_addr = server.http_server.http_addr().expect("http is set"); - let http_url = format!("http://{http_addr}").parse::().anyerr()?; - Ok((server, dns_addr, http_url)) + Ok(server) + } + + #[cfg(test)] + pub(crate) fn dns_addr(&self) -> SocketAddr { + self.dns_server.local_addr() + } + + #[cfg(test)] + pub(crate) fn http_url(&self) -> Option { + let http_addr = self.http_server.http_addr()?; + Some( + format!("http://{http_addr}") + .parse::() + .expect("valid url"), + ) + } + + #[cfg(test)] + pub(crate) fn https_url(&self) -> Option { + let https_addr = self.https_addr()?; + Some( + format!("https://{https_addr}") + .parse::() + .expect("valid url"), + ) + } + + #[cfg(test)] + pub(crate) fn https_addr(&self) -> Option { + self.http_server.https_addr() } }