From c0f30d8fe874c13d6b910b757fdb7eb3ea74447a Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 15 Mar 2026 00:11:28 +0000 Subject: [PATCH] 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. --- CHANGELOG.md | 7 ++ docs/design/fips-configuration.md | 2 +- docs/design/fips-intro.md | 2 +- docs/design/fips-mesh-layer.md | 4 +- docs/design/fips-transport-layer.md | 12 +- packaging/common/fips.yaml | 2 +- packaging/openwrt/files/etc/fips/fips.yaml | 2 +- packaging/systemd/README.install.md | 2 +- src/config/peer.rs | 6 +- src/transport/mod.rs | 41 ++++++- src/transport/tcp/mod.rs | 122 +++++++++++++++++++-- src/transport/udp/mod.rs | 113 ++++++++++++++++--- 12 files changed, 271 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed2a0b..09d7caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- DNS hostname support in peer addresses for UDP and TCP transports — addresses + can now use `host:port` with either IP addresses or DNS hostnames (e.g., + `"peer1.example.com:2121"`). DNS resolution with 60-second cache for UDP, + one-shot resolution at connect time for TCP. + ## [0.1.0] - 2026-03-12 ### Added (Initial Release) diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index 51e8aa7..f93150d 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -382,7 +382,7 @@ Static peer list. Each entry defines a peer to connect to. | `peers[].npub` | string | *(required)* | Peer's Nostr public key (npub-encoded) | | `peers[].alias` | string | *(none)* | Human-readable name for logging | | `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, or `ethernet` | -| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"ip:port"`. Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`) | +| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"host:port"` (IP or DNS hostname). Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`) | | `peers[].addresses[].priority` | u8 | `100` | Address priority (lower = preferred) | | `peers[].connect_policy` | string | `"auto_connect"` | Connection policy: `auto_connect`, `on_demand`, or `manual` | | `peers[].auto_reconnect` | bool | `true` | Automatically reconnect after MMP link-dead removal (exponential backoff, unlimited retries) | diff --git a/docs/design/fips-intro.md b/docs/design/fips-intro.md index 7ff8170..a2a7c3d 100644 --- a/docs/design/fips-intro.md +++ b/docs/design/fips-intro.md @@ -216,7 +216,7 @@ address prepends `fd` to the first 15 bytes of the node_addr, providing a ULA overlay address for unmodified IP applications via the TUN interface. Below the FIPS identity layer, each transport uses its own native addressing -— IP:port tuples, MAC addresses, .onion identifiers. These **link +— IP:port or hostname:port addresses, MAC addresses, .onion identifiers. These **link addresses** are opaque to everything above FMP and discarded once link authentication completes. diff --git a/docs/design/fips-mesh-layer.md b/docs/design/fips-mesh-layer.md index fde7ffe..5adb243 100644 --- a/docs/design/fips-mesh-layer.md +++ b/docs/design/fips-mesh-layer.md @@ -15,7 +15,7 @@ updates, coordinate discovery, and forwarded session datagrams — all encrypted per-hop. FMP is the boundary between opaque transport addresses and identified peers. -Below FMP, everything is transport-specific addresses (IP:port, MAC, .onion). +Below FMP, everything is transport-specific addresses (host:port, MAC, .onion). Above FMP, everything is peers identified by public keys and routable by node_addr. The transport layer never sees FIPS-level structure; FSP never sees transport addresses or routing details. @@ -280,7 +280,7 @@ authenticated peer regardless of what transport address the packet arrived from. FMP updates the peer's current address to the packet's source address, and subsequent outbound packets use the updated address. -This allows peers to change transport addresses (e.g., IP:port for UDP) +This allows peers to change transport addresses (e.g., host:port for UDP) without session interruption. The mechanism is: 1. Packet arrives from a different address than expected diff --git a/docs/design/fips-transport-layer.md b/docs/design/fips-transport-layer.md index 643a983..591811f 100644 --- a/docs/design/fips-transport-layer.md +++ b/docs/design/fips-transport-layer.md @@ -14,7 +14,7 @@ address, deliver the datagram to that address, and push inbound datagrams up to the FIPS Mesh Protocol (FMP) above. The transport layer deals exclusively in **transport addresses** — IP:port -tuples, MAC addresses, .onion identifiers, radio device addresses. These are +or hostname:port addresses, MAC addresses, .onion identifiers, radio device addresses. These are opaque to every layer above FMP. The mapping from transport address to FIPS identity happens at the link layer after the Noise IK link handshake completes. The word "peer" belongs to the link layer and above; the transport layer @@ -115,8 +115,8 @@ for internet connectivity: | Transport | Addressing | MTU | Reliability | Notes | | --------- | ---------- | --- | ----------- | ----- | -| UDP/IP | IP:port | 1280–1472 | Unreliable | Primary internet transport | -| TCP/IP | IP:port | Stream | Reliable | Requires length-prefix framing | +| UDP/IP | host:port | 1280–1472 | Unreliable | Primary internet transport | +| TCP/IP | host:port | Stream | Reliable | Requires length-prefix framing | | WebSocket | URL | Stream | Reliable | Browser-compatible | | Tor | .onion | Stream | Reliable | High latency, strong anonymity | @@ -364,7 +364,7 @@ optional `TcpListener` for inbound connections. | Property | Value | | -------- | ----- | -| Addressing | IP:port (same as UDP) | +| Addressing | host:port — IP address or DNS hostname | | Default MTU | 1400 bytes | | Per-link MTU | Derived from `TCP_MAXSEG` socket option | | Framing | FMP header-based (zero overhead) | @@ -480,7 +480,7 @@ X." FMP does not need to distinguish beacons from query responses. For internet-reachable transports, a node publishes a signed Nostr event containing its FIPS discovery information — public key and reachable -transport endpoints (UDP IP:port, TCP IP:port, .onion address). Other FIPS +transport endpoints (UDP host:port, TCP host:port, .onion address). Other FIPS nodes subscribing on the same relays learn about available peers. Nostr relay discovery is not a transport — it is a discovery service that @@ -588,7 +588,7 @@ on all forwarded datagrams. ### Transport Addresses Transport addresses (`TransportAddr`) are opaque byte vectors. The transport -layer interprets them (e.g., UDP parses "ip:port" strings); all layers above +layer interprets them (e.g., UDP/TCP resolve "host:port" strings (IP fast path, DNS fallback with 60s cache for UDP)); all layers above treat them as opaque handles passed back to the transport for sending. ### Transport State Machine diff --git a/packaging/common/fips.yaml b/packaging/common/fips.yaml index ea757ab..c226001 100644 --- a/packaging/common/fips.yaml +++ b/packaging/common/fips.yaml @@ -43,5 +43,5 @@ peers: [] # alias: "gateway" # addresses: # - transport: udp - # addr: "217.77.8.91:2121" # public FIPS testing node + # addr: "217.77.8.91:2121" # IP or hostname (e.g., "peer.example.com:2121") # connect_policy: auto_connect diff --git a/packaging/openwrt/files/etc/fips/fips.yaml b/packaging/openwrt/files/etc/fips/fips.yaml index aee3f6e..d59bcb8 100644 --- a/packaging/openwrt/files/etc/fips/fips.yaml +++ b/packaging/openwrt/files/etc/fips/fips.yaml @@ -61,4 +61,4 @@ peers: [] # alias: "gateway" # addresses: # - transport: udp - # addr: "1.2.3.4:2121" + # addr: "1.2.3.4:2121" # IP or hostname diff --git a/packaging/systemd/README.install.md b/packaging/systemd/README.install.md index 37c0a40..9770dcc 100644 --- a/packaging/systemd/README.install.md +++ b/packaging/systemd/README.install.md @@ -72,7 +72,7 @@ peers: alias: "gateway" addresses: - transport: udp - addr: "217.77.8.91:2121" # public FIPS testing node + addr: "217.77.8.91:2121" # IP or hostname (e.g., "peer.example.com:2121") connect_policy: auto_connect ``` diff --git a/src/config/peer.rs b/src/config/peer.rs index 6eec50a..f3ca28c 100644 --- a/src/config/peer.rs +++ b/src/config/peer.rs @@ -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). diff --git a/src/transport/mod.rs b/src/transport/mod.rs index e68bff1..143843f 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -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); @@ -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 { + 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::() { + 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 // ============================================================================ diff --git a/src/transport/tcp/mod.rs b/src/transport/tcp/mod.rs index 5d7deb8..472caf0 100644 --- a/src/transport/tcp/mod.rs +++ b/src/transport/tcp/mod.rs @@ -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>, 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 { - 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(); + } } diff --git a/src/transport/udp/mod.rs b/src/transport/udp/mod.rs index 1b9e1d8..179cda4 100644 --- a/src/transport/udp/mod.rs +++ b/src/transport/udp/mod.rs @@ -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, /// Transport statistics. stats: Arc, + /// DNS resolution cache for hostname addresses. + dns_cache: StdMutex>, } 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 { + // Fast path: try numeric IP parse (no cache, no DNS) + if let Some(s) = addr.as_str() + && let Ok(sock_addr) = s.parse::() + { + 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 { - 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(); + } }