diff --git a/src/builder.rs b/src/builder.rs index 8040b15c..785457d6 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -6,7 +6,7 @@ use super::{client::Client, node::Node}; use crate::chain::ChainState; use crate::network::ConnectionType; use crate::{BlockType, Config, FilterType}; -use crate::{Socks5Proxy, TrustedPeer}; +use crate::{DnsPeer, Socks5Proxy, TrustedPeer}; const MIN_PEERS: u8 = 1; const MAX_PEERS: u8 = 15; @@ -60,6 +60,20 @@ impl Builder { self } + /// Add a peer identified by hostname. The hostname is resolved via DNS on each + /// connection attempt, so if the IP changes between reconnections the node will + /// follow it. DNS peers are never consumed — they persist across reconnections. + pub fn add_dns_peer(mut self, peer: DnsPeer) -> Self { + self.config.dns_peers.push(peer); + self + } + + /// Add multiple DNS-based peers. + pub fn add_dns_peers(mut self, peers: impl IntoIterator) -> Self { + self.config.dns_peers.extend(peers); + self + } + /// Add a path to the directory where data should be stored. If none is provided, the current /// working directory will be used. pub fn data_dir(mut self, path: impl Into) -> Self { diff --git a/src/lib.rs b/src/lib.rs index dea59aa9..cfb30387 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -301,6 +301,33 @@ impl From for TrustedPeer { } } +/// A peer identified by hostname, resolved via DNS on each connection attempt. +/// +/// Unlike [`TrustedPeer`] (which stores a resolved IP), a `DnsPeer` stores a +/// hostname that is re-resolved every time the node needs to connect. This is +/// useful in environments where the peer's IP may change (e.g., Kubernetes +/// services, dynamic DNS). +/// +/// DNS peers are never consumed from the peer list — they persist and are +/// re-resolved on every reconnection attempt. +#[derive(Debug, Clone)] +pub struct DnsPeer { + /// The hostname to resolve (e.g., `"bitcoind.default.svc.cluster.local"`). + pub hostname: String, + /// The port to connect to. + pub port: u16, +} + +impl DnsPeer { + /// Create a new DNS-based peer. + pub fn new(hostname: impl Into, port: u16) -> Self { + Self { + hostname: hostname.into(), + port, + } + } +} + /// Route network traffic through a Socks5 proxy, typically used by a Tor daemon. #[derive(Debug, Clone)] pub struct Socks5Proxy(SocketAddr); @@ -342,6 +369,7 @@ enum NodeState { struct Config { required_peers: u8, white_list: Vec, + dns_peers: Vec, data_path: Option, chain_state: Option, connection_type: ConnectionType, @@ -355,6 +383,7 @@ impl Default for Config { Self { required_peers: 1, white_list: Default::default(), + dns_peers: Default::default(), data_path: Default::default(), chain_state: Default::default(), connection_type: Default::default(), diff --git a/src/network/peer_map.rs b/src/network/peer_map.rs index 9416c624..033c2b43 100644 --- a/src/network/peer_map.rs +++ b/src/network/peer_map.rs @@ -25,7 +25,7 @@ use crate::{ chain::HeightMonitor, default_port_from_network, network::{dns::bootstrap_dns, error::PeerError, peer::Peer, PeerId, PeerTimeoutConfig}, - BlockType, Dialog, TrustedPeer, + BlockType, Dialog, DnsPeer, TrustedPeer, }; use super::{AddressBook, ConnectionType, MainThreadMessage, PeerThreadMessage}; @@ -57,6 +57,8 @@ pub(crate) struct PeerMap { db: Arc>, connector: ConnectionType, whitelist: Whitelist, + dns_peers: Vec, + dns_peer_index: usize, dialog: Arc, timeout_config: PeerTimeoutConfig, } @@ -68,6 +70,7 @@ impl PeerMap { network: Network, block_type: BlockType, whitelist: Whitelist, + dns_peers: Vec, dialog: Arc, connection_type: ConnectionType, timeout_config: PeerTimeoutConfig, @@ -84,6 +87,8 @@ impl PeerMap { db: Arc::new(Mutex::new(AddressBook::new())), connector: connection_type, whitelist, + dns_peers, + dns_peer_index: 0, dialog, timeout_config, } @@ -229,8 +234,8 @@ impl PeerMap { false } - // Pull a peer from the configuration if we have one. If not, select a random peer from the database, - // as long as it is not from the same netgroup. If there are no peers in the database, try DNS. + // Pull a peer from the configuration if we have one. If not, try resolving DNS peers. + // If neither are available, select from the address book or bootstrap with DNS seeds. pub async fn next_peer(&mut self) -> Option { if let Some(peer) = self.whitelist.pop() { crate::debug!("Using a configured peer"); @@ -240,6 +245,29 @@ impl PeerMap { let record = Record::new(peer.address(), port, peer.known_services, &LOCAL_HOST); return Some(record); } + // Try resolving DNS peers (round-robin through all of them). + if !self.dns_peers.is_empty() { + let attempts = self.dns_peers.len(); + for _ in 0..attempts { + let idx = self.dns_peer_index % self.dns_peers.len(); + self.dns_peer_index = self.dns_peer_index.wrapping_add(1); + let dns_peer = &self.dns_peers[idx]; + let host_port = format!("{}:{}", dns_peer.hostname, dns_peer.port); + crate::debug!(format!("Resolving DNS peer: {host_port}")); + if let Ok(mut addrs) = tokio::net::lookup_host(&host_port).await { + if let Some(addr) = addrs.next() { + crate::debug!(format!("Resolved {host_port} to {addr}")); + let ip = match addr.ip() { + IpAddr::V4(ip) => AddrV2::Ipv4(ip), + IpAddr::V6(ip) => AddrV2::Ipv6(ip), + }; + let record = Record::new(ip, addr.port(), ServiceFlags::NONE, &LOCAL_HOST); + return Some(record); + } + } + crate::debug!(format!("Failed to resolve DNS peer: {host_port}")); + } + } let mut db_lock = self.db.lock().await; if db_lock.is_empty() { crate::debug!("Bootstrapping peers with DNS"); diff --git a/src/node.rs b/src/node.rs index 94bd4293..550401c5 100644 --- a/src/node.rs +++ b/src/node.rs @@ -70,6 +70,7 @@ impl Node { let Config { required_peers, white_list, + dns_peers, data_path: _, chain_state, connection_type, @@ -95,6 +96,7 @@ impl Node { network, block_type, white_list, + dns_peers, Arc::clone(&dialog), connection_type, peer_timeout_config, diff --git a/tests/core.rs b/tests/core.rs index 5d62e984..878665d4 100644 --- a/tests/core.rs +++ b/tests/core.rs @@ -8,7 +8,7 @@ use bip157::{ chain::{checkpoints::HeaderCheckpoint, BlockHeaderChanges, ChainState}, client::Client, node::Node, - Address, BlockHash, Event, Info, ServiceFlags, Transaction, TrustedPeer, Warning, + Address, BlockHash, DnsPeer, Event, Info, ServiceFlags, Transaction, TrustedPeer, Warning, }; use bitcoin::{ absolute, @@ -633,6 +633,42 @@ async fn tx_can_broadcast() { .unwrap(); } +#[tokio::test] +async fn dns_peer_sync() { + let (bitcoind, socket_addr) = start_bitcoind(true).unwrap(); + let rpc = &bitcoind.client; + let tempdir = tempfile::TempDir::new().unwrap().path().to_owned(); + let miner = rpc.new_address().unwrap(); + mine_blocks(rpc, &miner, 10, 2).await; + let best = best_hash(rpc); + // Exercise the full DnsPeer code path: round-robin selection in next_peer(), + // resolution via tokio::net::lookup_host, Record creation, and connection. + // We use an IP literal here because "localhost" resolves to [::1] on macOS + // while bitcoind binds to IPv4 only. Real DNS resolution of hostnames is + // covered by the dns_works test above. + let dns_peer = DnsPeer::new(socket_addr.ip().to_string(), socket_addr.port()); + let builder = bip157::builder::Builder::new(bitcoin::Network::Regtest) + .chain_state(ChainState::Checkpoint(HeaderCheckpoint::from_genesis( + bitcoin::Network::Regtest, + ))) + .add_dns_peer(dns_peer) + .data_dir(tempdir); + let (node, client) = builder.build(); + tokio::task::spawn(async move { node.run().await }); + let Client { + requester, + info_rx, + warn_rx, + event_rx: mut channel, + } = client; + tokio::task::spawn(async move { print_logs(info_rx, warn_rx).await }); + sync_assert(&best, &mut channel).await; + let cp = requester.chain_tip().await.unwrap(); + assert_eq!(cp.hash, best); + requester.shutdown().unwrap(); + rpc.stop().unwrap(); +} + #[tokio::test] async fn dns_works() { let hostname = bip157::lookup_host("seed.bitcoin.sipa.be").await;