diff --git a/Cargo.lock b/Cargo.lock index b16781dced..aaf4c8029d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2525,6 +2525,7 @@ dependencies = [ "iroh-net", "iroh-test", "lru", + "mainline", "parking_lot", "pkarr", "rcgen 0.12.1", @@ -2545,6 +2546,7 @@ dependencies = [ "tower_governor", "tracing", "tracing-subscriber", + "ttl_cache", "url", "z32", ] @@ -2873,6 +2875,26 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "mainline" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907bd8136f5eb985f3c0faa70051d4809c7e0cfbcc5700bec2ceb5df0de118ca" +dependencies = [ + "bytes", + "crc", + "ed25519-dalek", + "flume", + "lru", + "rand", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror", + "tracing", +] + [[package]] name = "match_cfg" version = "0.1.0" @@ -3501,6 +3523,7 @@ checksum = "242ae92dfb9d2ba3aaa9caf4723e72043bc50729ad05a763771771ba03196ffb" dependencies = [ "bytes", "ed25519-dalek", + "mainline", "rand", "reqwest", "self_cell", @@ -4560,6 +4583,25 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[package]] +name = "serde_bytes" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8497c313fd43ab992087548117643f6fcd935cbf36f176ffda0aacf9591734" +dependencies = [ + "serde", +] + [[package]] name = "serde_derive" version = "1.0.198" @@ -4673,6 +4715,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "sha2" version = "0.10.8" diff --git a/iroh-dns-server/Cargo.toml b/iroh-dns-server/Cargo.toml index b9c571116c..35dd840244 100644 --- a/iroh-dns-server/Cargo.toml +++ b/iroh-dns-server/Cargo.toml @@ -27,7 +27,7 @@ http = "1.0.0" iroh-metrics = { version = "0.14.0", path = "../iroh-metrics" } lru = "0.12.3" parking_lot = "0.12.1" -pkarr = { version = "1.1.2", features = [ "async", "relay"], default_features = false } +pkarr = { version = "1.1.4", features = [ "async", "relay", "dht"], default_features = false } rcgen = "0.12.1" redb = "2.0.0" regex = "1.10.3" @@ -46,6 +46,7 @@ tower-http = { version = "0.5.2", features = ["cors", "trace"] } tower_governor = "0.3.2" tracing = "0.1.40" tracing-subscriber = "0.3.18" +ttl_cache = "0.5.1" url = "2.5.0" z32 = "1.1.1" @@ -53,3 +54,4 @@ z32 = "1.1.1" hickory-resolver = "0.24.0" iroh-net = { version = "0.14.0", path = "../iroh-net" } iroh-test = { path = "../iroh-test" } +mainline = "<1.5.0" diff --git a/iroh-dns-server/config.dev.toml b/iroh-dns-server/config.dev.toml index 80db595573..a43b10364a 100644 --- a/iroh-dns-server/config.dev.toml +++ b/iroh-dns-server/config.dev.toml @@ -16,3 +16,6 @@ default_ttl = 900 origins = ["irohdns.example.", "."] rr_a = "127.0.0.1" rr_ns = "ns1.irohdns.example." + +[mainline] +enabled = true diff --git a/iroh-dns-server/config.prod.toml b/iroh-dns-server/config.prod.toml index 8dde5fb6ba..64b3f88f67 100644 --- a/iroh-dns-server/config.prod.toml +++ b/iroh-dns-server/config.prod.toml @@ -11,3 +11,6 @@ default_ttl = 30 origins = ["irohdns.example.org", "."] rr_a = "203.0.10.10" rr_ns = "ns1.irohdns.example.org." + +[mainline] +enabled = false diff --git a/iroh-dns-server/src/config.rs b/iroh-dns-server/src/config.rs index d68f7663cc..89222f9daf 100644 --- a/iroh-dns-server/src/config.rs +++ b/iroh-dns-server/src/config.rs @@ -40,6 +40,9 @@ pub struct Config { /// The metrics server is started by default. To disable the metrics server, set to /// `Some(MetricsConfig::disabled())`. pub metrics: Option, + + /// Config for the mainline lookup. + pub mainline: Option, } /// The config for the metrics server. @@ -61,6 +64,39 @@ impl MetricsConfig { } } +/// The config for the metrics server. +#[derive(Debug, Serialize, Deserialize)] +pub struct MainlineConfig { + /// Set to true to enable the mainline lookup. + pub enabled: bool, + /// Set custom bootstrap nodes. + /// + /// Addresses can either be `domain:port` or `ipv4:port`. + /// + /// If empty this will use the default bittorrent mainline bootstrap nodes as defined by pkarr. + pub bootstrap: Option>, +} + +/// Configure the bootstrap servers for mainline DHT resolution. +#[derive(Debug, Serialize, Deserialize, Default)] +pub enum BootstrapOption { + /// Use the default bootstrap servers. + #[default] + Default, + /// Use custom bootstrap servers. + Custom(Vec), +} + +#[allow(clippy::derivable_impls)] +impl Default for MainlineConfig { + fn default() -> Self { + Self { + enabled: false, + bootstrap: None, + } + } +} + impl Config { /// Load the config from a file. pub async fn load(path: impl AsRef) -> Result { @@ -103,6 +139,20 @@ impl Config { }, } } + + pub(crate) fn mainline_enabled(&self) -> Option { + match self.mainline.as_ref() { + None => None, + Some(MainlineConfig { enabled: false, .. }) => None, + Some(MainlineConfig { + bootstrap: Some(bootstrap), + .. + }) => Some(BootstrapOption::Custom(bootstrap.clone())), + Some(MainlineConfig { + bootstrap: None, .. + }) => Some(BootstrapOption::Default), + } + } } impl Default for Config { @@ -134,6 +184,7 @@ impl Default for Config { rr_ns: Some("ns1.irohdns.example.".to_string()), }, metrics: None, + mainline: None, } } } diff --git a/iroh-dns-server/src/lib.rs b/iroh-dns-server/src/lib.rs index bb62a969e9..95e09bfc1e 100644 --- a/iroh-dns-server/src/lib.rs +++ b/iroh-dns-server/src/lib.rs @@ -28,10 +28,10 @@ mod tests { }, key::SecretKey, }; - use pkarr::SignedPacket; + use pkarr::{PkarrClient, SignedPacket}; use url::Url; - use crate::server::Server; + use crate::{config::BootstrapOption, server::Server}; #[tokio::test] async fn pkarr_publish_dns_resolve() -> Result<()> { @@ -177,6 +177,45 @@ mod tests { Ok(()) } + #[tokio::test] + async fn integration_mainline() -> Result<()> { + iroh_test::logging::setup_multithreaded(); + + // run a mainline testnet + let testnet = mainline::dht::Testnet::new(5); + let bootstrap = testnet.bootstrap.clone(); + + // spawn our server with mainline support + let (server, nameserver, _http_url) = + Server::spawn_for_tests_with_mainline(Some(BootstrapOption::Custom(bootstrap))).await?; + + let origin = "irohdns.example."; + + // create a signed packet + let secret_key = SecretKey::generate(); + let node_id = secret_key.public(); + let relay_url: Url = "https://relay.example.".parse()?; + let node_info = NodeInfo::new(node_id, Some(relay_url.clone()), Default::default()); + let signed_packet = node_info.to_pkarr_signed_packet(&secret_key, 30)?; + + // publish the signed packet to our DHT + let pkarr = PkarrClient::builder().bootstrap(&testnet.bootstrap).build(); + pkarr.publish(&signed_packet).await?; + + // resolve via DNS from our server, which will lookup from our DHT + let resolver = test_resolver(nameserver); + let res = lookup_by_id(&resolver, &node_id, origin).await?; + + assert_eq!(res.node_id, node_id); + assert_eq!(res.info.relay_url.map(Url::from), Some(relay_url)); + + server.shutdown().await?; + for node in testnet.nodes { + node.shutdown(); + } + Ok(()) + } + fn test_resolver(nameserver: SocketAddr) -> DnsResolver { let mut config = ResolverConfig::new(); let nameserver_config = NameServerConfig::new(nameserver, Protocol::Udp); diff --git a/iroh-dns-server/src/main.rs b/iroh-dns-server/src/main.rs index 511a4c58db..a0f88aec51 100644 --- a/iroh-dns-server/src/main.rs +++ b/iroh-dns-server/src/main.rs @@ -28,8 +28,10 @@ async fn main() -> Result<()> { let args = Cli::parse(); let config = if let Some(path) = args.config { + debug!("loading config from {:?}", path); Config::load(path).await? } else { + debug!("using default config"); Config::default() }; diff --git a/iroh-dns-server/src/server.rs b/iroh-dns-server/src/server.rs index 2b952a8c51..c9580fa121 100644 --- a/iroh-dns-server/src/server.rs +++ b/iroh-dns-server/src/server.rs @@ -14,7 +14,11 @@ use crate::{ /// Spawn the server and run until the `Ctrl-C` signal is received, then shutdown. pub async fn run_with_config_until_ctrl_c(config: Config) -> Result<()> { - let store = ZoneStore::persistent(Config::signed_packet_store_path()?)?; + let mut store = ZoneStore::persistent(Config::signed_packet_store_path()?)?; + if let Some(bootstrap) = config.mainline_enabled() { + info!("mainline fallback enabled"); + store = store.with_mainline_fallback(bootstrap); + }; let server = Server::spawn(config, store).await?; tokio::signal::ctrl_c().await?; info!("shutdown"); @@ -86,6 +90,15 @@ impl Server { /// HTTP server. #[cfg(test)] pub async fn spawn_for_tests() -> Result<(Self, std::net::SocketAddr, url::Url)> { + Self::spawn_for_tests_with_mainline(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_mainline( + mainline: Option, + ) -> Result<(Self, std::net::SocketAddr, url::Url)> { use crate::config::MetricsConfig; use std::net::{IpAddr, Ipv4Addr}; @@ -97,7 +110,11 @@ impl Server { config.https = None; config.metrics = Some(MetricsConfig::disabled()); - let store = ZoneStore::in_memory()?; + let mut store = ZoneStore::in_memory()?; + if let Some(bootstrap) = mainline { + info!("mainline fallback enabled"); + store = store.with_mainline_fallback(bootstrap); + } let server = Self::spawn(config, store).await?; let dns_addr = server.dns_server.local_addr(); let http_addr = server.http_server.http_addr().expect("http is set"); diff --git a/iroh-dns-server/src/store.rs b/iroh-dns-server/src/store.rs index 5877d00906..dd8d911911 100644 --- a/iroh-dns-server/src/store.rs +++ b/iroh-dns-server/src/store.rs @@ -1,15 +1,18 @@ //! Pkarr packet store used to resolve DNS queries. -use std::{collections::BTreeMap, num::NonZeroUsize, path::Path, sync::Arc}; +use std::{collections::BTreeMap, num::NonZeroUsize, path::Path, sync::Arc, time::Duration}; use anyhow::Result; use hickory_proto::rr::{Name, RecordSet, RecordType, RrKey}; use iroh_metrics::inc; use lru::LruCache; use parking_lot::Mutex; -use pkarr::SignedPacket; +use pkarr::{PkarrClient, SignedPacket}; +use tracing::{debug, trace}; +use ttl_cache::TtlCache; use crate::{ + config::BootstrapOption, metrics::Metrics, util::{signed_packet_to_hickory_records_without_origin, PublicKeyBytes}, }; @@ -20,6 +23,8 @@ mod signed_packets; /// Cache up to 1 million pkarr zones by default pub const DEFAULT_CACHE_CAPACITY: usize = 1024 * 1024; +/// Default TTL for DHT cache entries +pub const DHT_CACHE_TTL: Duration = Duration::from_secs(300); /// Where a new pkarr packet comes from pub enum PacketSource { @@ -35,6 +40,7 @@ pub enum PacketSource { pub struct ZoneStore { cache: Arc>, store: Arc, + pkarr: Option>, } impl ZoneStore { @@ -50,17 +56,36 @@ impl ZoneStore { Ok(Self::new(packet_store)) } + /// Configure a pkarr client for resolution of packets from the bittorent mainline DHT. + /// + /// This will be used only as a fallback if there is no local info available. + /// + /// Optionally set custom bootstrap nodes. If `bootstrap` is empty it will use the default + /// mainline bootstrap nodes. + pub fn with_mainline_fallback(self, bootstrap: BootstrapOption) -> Self { + let pkarr_client = match bootstrap { + BootstrapOption::Default => PkarrClient::default(), + BootstrapOption::Custom(bootstrap) => { + PkarrClient::builder().bootstrap(&bootstrap).build() + } + }; + Self { + pkarr: Some(Arc::new(pkarr_client)), + ..self + } + } + /// Create a new zone store. pub fn new(store: SignedPacketStore) -> Self { let zone_cache = ZoneCache::new(DEFAULT_CACHE_CAPACITY); Self { store: Arc::new(store), cache: Arc::new(Mutex::new(zone_cache)), + pkarr: None, } } /// Resolve a DNS query. - // allow unused async: this will be async soon. #[allow(clippy::unused_async)] pub async fn resolve( &self, @@ -68,6 +93,7 @@ impl ZoneStore { name: &Name, record_type: RecordType, ) -> Result>> { + tracing::info!("{} {}", name, record_type); if let Some(rset) = self.cache.lock().resolve(pubkey, name, record_type) { return Ok(Some(rset)); } @@ -79,8 +105,23 @@ impl ZoneStore { .insert_and_resolve(&packet, name, record_type); }; - // This would be where mainline discovery could be added. - + if let Some(pkarr) = self.pkarr.as_ref() { + let key = pkarr::PublicKey::try_from(*pubkey.as_bytes()).expect("valid public key"); + // use the more expensive `resolve_most_recent` here. + // + // it will be cached for some time. + debug!("DHT resolve {}", key.to_z32()); + let packet_opt = pkarr.as_ref().resolve_most_recent(key).await; + if let Some(packet) = packet_opt { + debug!("DHT resolve successful {:?}", packet.packet()); + return self + .cache + .lock() + .insert_and_resolve_dht(&packet, name, record_type); + } else { + debug!("DHT resolve failed"); + } + } Ok(None) } @@ -110,15 +151,21 @@ impl ZoneStore { } } -#[derive(Debug)] +#[derive(derive_more::Debug)] struct ZoneCache { + /// Cache for explicitly added entries cache: LruCache, + /// Cache for DHT entries, this must have a finite TTL + /// so we don't cache stale entries indefinitely. + #[debug("dht_cache")] + dht_cache: TtlCache, } impl ZoneCache { fn new(cap: usize) -> Self { let cache = LruCache::new(NonZeroUsize::new(cap).expect("capacity must be larger than 0")); - Self { cache } + let dht_cache = TtlCache::new(cap); + Self { cache, dht_cache } } fn resolve( @@ -127,9 +174,16 @@ impl ZoneCache { name: &Name, record_type: RecordType, ) -> Option> { - self.cache - .get(pubkey) - .and_then(|zone| zone.resolve(name, record_type)) + let zone = if let Some(zone) = self.cache.get(pubkey) { + trace!("cache hit {}", pubkey.to_z32()); + zone + } else if let Some(zone) = self.dht_cache.get(pubkey) { + trace!("dht cache hit {}", pubkey.to_z32()); + zone + } else { + return None; + }; + zone.resolve(name, record_type) } fn insert_and_resolve( @@ -143,6 +197,19 @@ impl ZoneCache { Ok(self.resolve(&pubkey, name, record_type)) } + fn insert_and_resolve_dht( + &mut self, + signed_packet: &SignedPacket, + name: &Name, + record_type: RecordType, + ) -> Result>> { + let pubkey = PublicKeyBytes::from_signed_packet(signed_packet); + let zone = CachedZone::from_signed_packet(signed_packet)?; + let res = zone.resolve(name, record_type); + self.dht_cache.insert(pubkey, zone, DHT_CACHE_TTL); + Ok(res) + } + fn insert(&mut self, signed_packet: &SignedPacket) -> Result<()> { let pubkey = PublicKeyBytes::from_signed_packet(signed_packet); if self @@ -160,6 +227,7 @@ impl ZoneCache { fn remove(&mut self, pubkey: &PublicKeyBytes) { self.cache.pop(pubkey); + self.dht_cache.remove(pubkey); } } @@ -185,6 +253,9 @@ impl CachedZone { fn resolve(&self, name: &Name, record_type: RecordType) -> Option> { let key = RrKey::new(name.into(), record_type); + for record in self.records.keys() { + tracing::info!("record {:?}", record); + } self.records.get(&key).cloned() } } diff --git a/iroh-dns-server/src/util.rs b/iroh-dns-server/src/util.rs index 0b64f34b2e..23740366bc 100644 --- a/iroh-dns-server/src/util.rs +++ b/iroh-dns-server/src/util.rs @@ -16,7 +16,9 @@ use hickory_proto::{ }; use pkarr::SignedPacket; -#[derive(derive_more::From, derive_more::Into, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive( + derive_more::From, derive_more::Into, Eq, PartialEq, Ord, PartialOrd, Hash, Clone, Copy, +)] pub struct PublicKeyBytes([u8; 32]); impl PublicKeyBytes { @@ -26,11 +28,11 @@ impl PublicKeyBytes { Ok(Self(bytes)) } - pub fn to_z32(&self) -> String { + pub fn to_z32(self) -> String { z32::encode(&self.0) } - pub fn to_bytes(&self) -> [u8; 32] { + pub fn to_bytes(self) -> [u8; 32] { self.0 } diff --git a/iroh-net/Cargo.toml b/iroh-net/Cargo.toml index c4b5852e57..32126840ba 100644 --- a/iroh-net/Cargo.toml +++ b/iroh-net/Cargo.toml @@ -43,7 +43,7 @@ libc = "0.2.139" num_enum = "0.7" once_cell = "1.18.0" parking_lot = "0.12.1" -pkarr = { version = "1.1.3", default-features = false, features = ["async", "relay"] } +pkarr = { version = "1.1.4", default-features = false, features = ["async", "relay"] } postcard = { version = "1", default-features = false, features = ["alloc", "use-std", "experimental-derive"] } quinn = "0.10" quinn-proto = "0.10.5"