Add DNS hostname support in peer addresses for UDP and TCP transports

Add resolve_socket_addr() with IP fast path and tokio::net::lookup_host()
fallback for DNS hostnames. Peer addresses can now use hostnames like
"peer1.example.com:2121" alongside IP addresses.

UDP transport adds a per-transport DNS cache (60s TTL) to avoid
per-packet resolution. TCP resolves at connect time (one-shot).

Update design docs, config examples, and changelog to reflect hostname
support in transport addressing.
This commit is contained in:
Johnathan Corgan
2026-03-15 00:49:54 +00:00
parent d873d0e00e
commit c0f30d8fe8
12 changed files with 271 additions and 44 deletions
+3 -3
View File
@@ -35,9 +35,9 @@ pub struct PeerAddress {
/// Transport-specific address string.
///
/// Format depends on transport type:
/// - UDP: "host:port" (e.g., "192.168.1.1:2121")
/// - Tor: "onion_address:port" (e.g., "xyz...abc.onion:2121")
/// - Ethernet: "interface/mac" (future)
/// - UDP/TCP: "host:port" — IP address or DNS hostname
/// (e.g., "192.168.1.1:2121" or "peer1.example.com:2121")
/// - Ethernet: "interface/mac" (e.g., "eth0/aa:bb:cc:dd:ee:ff")
pub addr: String,
/// Priority for address selection (lower = preferred).
+39 -2
View File
@@ -16,6 +16,7 @@ use tcp::TcpTransport;
#[cfg(target_os = "linux")]
use ethernet::EthernetTransport;
use std::fmt;
use std::net::SocketAddr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
@@ -362,9 +363,8 @@ impl fmt::Display for LinkDirection {
/// Opaque transport-specific address.
///
/// Each transport type interprets this differently:
/// - UDP: "ip:port"
/// - UDP/TCP: "host:port" (IP address or DNS hostname)
/// - Ethernet: MAC address (6 bytes)
/// - Tor: ".onion:port"
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct TransportAddr(Vec<u8>);
@@ -1081,6 +1081,43 @@ impl TransportHandle {
}
}
// ============================================================================
// DNS Resolution
// ============================================================================
/// Resolve a TransportAddr to a SocketAddr.
///
/// Fast path: if the address parses as a numeric IP:port, returns
/// immediately with no DNS lookup. Otherwise, treats the address as
/// `hostname:port` and performs async DNS resolution via the system
/// resolver.
pub(crate) async fn resolve_socket_addr(
addr: &TransportAddr,
) -> Result<SocketAddr, TransportError> {
let s = addr
.as_str()
.ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?;
// Fast path: numeric IP address — no DNS lookup
if let Ok(sock_addr) = s.parse::<SocketAddr>() {
return Ok(sock_addr);
}
// Slow path: DNS resolution
tokio::net::lookup_host(s)
.await
.map_err(|e| {
TransportError::InvalidAddress(format!("DNS resolution failed for {}: {}", s, e))
})?
.next()
.ok_or_else(|| {
TransportError::InvalidAddress(format!(
"DNS resolution returned no addresses for {}",
s
))
})
}
// ============================================================================
// Tests
// ============================================================================
+112 -10
View File
@@ -25,6 +25,7 @@
pub mod stats;
pub mod stream;
use super::resolve_socket_addr;
use super::{
ConnectionState, DiscoveredPeer, PacketTx, ReceivedPacket, Transport, TransportAddr,
TransportError, TransportId, TransportState, TransportType,
@@ -339,7 +340,7 @@ impl TcpTransport {
&self,
addr: &TransportAddr,
) -> Result<Arc<Mutex<OwnedWriteHalf>>, TransportError> {
let socket_addr = parse_socket_addr(addr)?;
let socket_addr = resolve_socket_addr(addr).await?;
let timeout_ms = self.config.connect_timeout_ms();
// Connect with timeout
@@ -453,7 +454,11 @@ impl TcpTransport {
}
}
let socket_addr = parse_socket_addr(addr)?;
// Validate address is UTF-8 before spawning (fail fast on bad input)
let addr_string = addr
.as_str()
.ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?
.to_string();
let timeout_ms = self.config.connect_timeout_ms();
let config = self.config.clone();
let transport_id = self.transport_id;
@@ -467,6 +472,27 @@ impl TcpTransport {
);
let task = tokio::spawn(async move {
// Resolve address (may involve DNS for hostnames)
let socket_addr: SocketAddr = if let Ok(sa) = addr_string.parse() {
sa
} else {
tokio::net::lookup_host(&addr_string)
.await
.map_err(|e| {
TransportError::InvalidAddress(format!(
"DNS resolution failed for {}: {}",
addr_string, e
))
})?
.next()
.ok_or_else(|| {
TransportError::InvalidAddress(format!(
"DNS resolution returned no addresses for {}",
addr_string
))
})?
};
// Connect with timeout
let stream = match tokio::time::timeout(
Duration::from_millis(timeout_ms),
@@ -903,14 +929,6 @@ async fn tcp_receive_loop(
// Socket Configuration Helpers
// ============================================================================
/// Parse a TransportAddr as SocketAddr.
fn parse_socket_addr(addr: &TransportAddr) -> Result<SocketAddr, TransportError> {
addr.as_str()
.ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?
.parse()
.map_err(|e| TransportError::InvalidAddress(format!("{}", e)))
}
/// Configure a TCP socket with the transport's settings.
fn configure_socket(
stream: &std::net::TcpStream,
@@ -1511,4 +1529,88 @@ mod tests {
);
assert_eq!(state, ConnectionState::None);
}
#[tokio::test]
async fn test_connect_ip_string() {
let (tx1, _rx1) = packet_channel(100);
let (tx2, mut rx2) = packet_channel(100);
let mut t1 = TcpTransport::new(TransportId::new(1), None, make_config(), tx1);
let mut t2 = TcpTransport::new(
TransportId::new(2),
None,
TcpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
..Default::default()
},
tx2,
);
t1.start_async().await.unwrap();
t2.start_async().await.unwrap();
let port2 = t2.local_addr().unwrap().port();
// Connect using IP string — build a valid FMP frame (114 bytes)
let addr = TransportAddr::from_string(&format!("127.0.0.1:{}", port2));
let mut frame = vec![0xAA; 114];
frame[0] = 0x01; // ver=0, phase=1
frame[1] = 0x00; // flags
frame[2..4].copy_from_slice(&110u16.to_le_bytes()); // payload_len
t1.send_async(&addr, &frame).await.unwrap();
// Receive on t2
let packet = tokio::time::timeout(Duration::from_secs(5), rx2.recv())
.await
.expect("timeout")
.expect("channel closed");
assert_eq!(packet.data, frame);
t1.stop_async().await.unwrap();
t2.stop_async().await.unwrap();
}
#[tokio::test]
async fn test_connect_async_ip_string() {
let (tx1, _rx1) = packet_channel(100);
let (tx2, _rx2) = packet_channel(100);
let mut t1 = TcpTransport::new(TransportId::new(1), None, make_config(), tx1);
let mut t2 = TcpTransport::new(
TransportId::new(2),
None,
TcpConfig {
bind_addr: Some("127.0.0.1:0".to_string()),
..Default::default()
},
tx2,
);
t1.start_async().await.unwrap();
t2.start_async().await.unwrap();
let port2 = t2.local_addr().unwrap().port();
let addr = TransportAddr::from_string(&format!("127.0.0.1:{}", port2));
// Non-blocking connect via IP string
t1.connect_async(&addr).await.unwrap();
// Poll until connected
for _ in 0..50 {
let state = t1.connection_state_sync(&addr);
if state == ConnectionState::Connected {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(
t1.connection_state_sync(&addr),
ConnectionState::Connected,
);
t1.stop_async().await.unwrap();
t2.stop_async().await.unwrap();
}
}
+97 -16
View File
@@ -10,12 +10,18 @@ mod socket;
mod stats;
use socket::{AsyncUdpSocket, UdpRawSocket};
use stats::UdpStats;
use super::resolve_socket_addr;
use crate::config::UdpConfig;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use tokio::task::JoinHandle;
use tracing::{debug, info, trace, warn};
/// DNS cache TTL for hostname resolution (60 seconds).
const DNS_CACHE_TTL: Duration = Duration::from_secs(60);
/// UDP transport for FIPS.
///
/// Provides connectionless, unreliable packet delivery over UDP/IP.
@@ -40,6 +46,8 @@ pub struct UdpTransport {
local_addr: Option<SocketAddr>,
/// Transport statistics.
stats: Arc<UdpStats>,
/// DNS resolution cache for hostname addresses.
dns_cache: StdMutex<HashMap<TransportAddr, (SocketAddr, Instant)>>,
}
impl UdpTransport {
@@ -60,6 +68,7 @@ impl UdpTransport {
recv_task: None,
local_addr: None,
stats: Arc::new(UdpStats::new()),
dns_cache: StdMutex::new(HashMap::new()),
}
}
@@ -78,6 +87,41 @@ impl UdpTransport {
&self.stats
}
/// Resolve a transport address, using cached results for hostnames.
///
/// Numeric IP addresses bypass the cache entirely. Hostnames are
/// resolved via DNS and cached for `DNS_CACHE_TTL` to avoid
/// per-packet resolution overhead.
async fn resolve_cached(&self, addr: &TransportAddr) -> Result<SocketAddr, TransportError> {
// Fast path: try numeric IP parse (no cache, no DNS)
if let Some(s) = addr.as_str()
&& let Ok(sock_addr) = s.parse::<SocketAddr>()
{
return Ok(sock_addr);
}
// Check cache
{
let cache = self.dns_cache.lock().unwrap();
if let Some((resolved, cached_at)) = cache.get(addr)
&& cached_at.elapsed() < DNS_CACHE_TTL
{
return Ok(*resolved);
}
}
// Cache miss or expired — resolve via DNS
let resolved = resolve_socket_addr(addr).await?;
// Store in cache
{
let mut cache = self.dns_cache.lock().unwrap();
cache.insert(addr.clone(), (resolved, Instant::now()));
}
Ok(resolved)
}
/// Query transport-local congestion indicators.
pub fn congestion(&self) -> super::TransportCongestion {
super::TransportCongestion {
@@ -194,7 +238,7 @@ impl UdpTransport {
});
}
let socket_addr = parse_socket_addr(addr)?;
let socket_addr = self.resolve_cached(addr).await?;
let socket = self.socket.as_ref().ok_or(TransportError::NotStarted)?;
match socket.send_to(data, &socket_addr).await {
@@ -261,14 +305,6 @@ impl Transport for UdpTransport {
}
}
/// Parse a TransportAddr as SocketAddr.
fn parse_socket_addr(addr: &TransportAddr) -> Result<SocketAddr, TransportError> {
addr.as_str()
.ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?
.parse()
.map_err(|e| TransportError::InvalidAddress(format!("{}", e)))
}
/// UDP receive loop - runs as a spawned task.
async fn udp_receive_loop(
socket: AsyncUdpSocket,
@@ -528,17 +564,29 @@ mod tests {
));
}
#[test]
fn test_parse_socket_addr() {
#[tokio::test]
async fn test_resolve_socket_addr_ip() {
let addr = TransportAddr::from_string("192.168.1.1:2121");
let result = parse_socket_addr(&addr).unwrap();
let result = resolve_socket_addr(&addr).await.unwrap();
assert_eq!(result.to_string(), "192.168.1.1:2121");
}
let invalid = TransportAddr::from_string("not_an_address");
assert!(parse_socket_addr(&invalid).is_err());
#[tokio::test]
async fn test_resolve_socket_addr_invalid() {
let invalid = TransportAddr::from_string("nonexistent.invalid:2121");
assert!(resolve_socket_addr(&invalid).await.is_err());
let binary = TransportAddr::new(vec![0xff, 0x80]);
assert!(parse_socket_addr(&binary).is_err());
assert!(resolve_socket_addr(&binary).await.is_err());
}
#[tokio::test]
async fn test_resolve_socket_addr_hostname() {
let addr = TransportAddr::from_string("localhost:2121");
let result = resolve_socket_addr(&addr).await.unwrap();
// localhost should resolve to 127.0.0.1 or [::1]
assert!(result.ip().is_loopback());
assert_eq!(result.port(), 2121);
}
#[tokio::test]
@@ -550,4 +598,37 @@ mod tests {
let cong = transport.congestion();
assert_eq!(cong.recv_drops, Some(0));
}
#[tokio::test]
async fn test_send_recv_ip_string() {
let (tx1, _rx1) = packet_channel(100);
let (tx2, mut rx2) = packet_channel(100);
let mut t1 = UdpTransport::new(TransportId::new(1), None, make_config(0), tx1);
let mut t2 = UdpTransport::new(TransportId::new(2), None, make_config(0), tx2);
t1.start_async().await.unwrap();
t2.start_async().await.unwrap();
let port2 = t2.local_addr().unwrap().port();
// Send using IP string address
let data = b"hello via ip string";
let bytes_sent = t1
.send_async(&TransportAddr::from_string(&format!("127.0.0.1:{}", port2)), data)
.await
.unwrap();
assert_eq!(bytes_sent, data.len());
// Receive on t2
let packet = timeout(Duration::from_secs(1), rx2.recv())
.await
.expect("timeout")
.expect("channel closed");
assert_eq!(packet.data, data);
t1.stop_async().await.unwrap();
t2.stop_async().await.unwrap();
}
}