From 5f7e2633f0b1c327c3a3b1d512fdada908ecf621 Mon Sep 17 00:00:00 2001 From: Siddharth Sharma Date: Thu, 9 Apr 2026 14:55:29 -0700 Subject: [PATCH 1/2] feat: add DnsPeer for hostname-based peers with fresh DNS resolution Add DnsPeer type that stores a hostname instead of a resolved IP. DNS peers are re-resolved via tokio::net::lookup_host on every connection attempt, so if the IP behind the hostname changes (e.g., Kubernetes service rotation), the node follows it automatically. Unlike TrustedPeer (consumed on use), DNS peers persist in the peer list and are tried round-robin after the IP whitelist is exhausted. New Builder API: .add_dns_peer(DnsPeer::new("bitcoind.svc.local", 8333)) .add_dns_peers(vec![...]) --- src/builder.rs | 16 +++++++++++++++- src/lib.rs | 29 +++++++++++++++++++++++++++++ src/network/peer_map.rs | 34 +++++++++++++++++++++++++++++++--- src/node.rs | 2 ++ 4 files changed, 77 insertions(+), 4 deletions(-) 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, From 282078fce83f17970d4b53fa7475606731093a26 Mon Sep 17 00:00:00 2001 From: Siddharth Sharma Date: Thu, 9 Apr 2026 15:21:07 -0700 Subject: [PATCH 2/2] test: add integration test for DnsPeer Add dns_peer_sync test that builds a node with a DnsPeer pointing at the local bitcoind and verifies the node resolves the hostname, connects, and syncs the chain successfully. --- tests/core.rs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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;