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:11:28 +00:00
parent d873d0e00e
commit c0f30d8fe8
12 changed files with 271 additions and 44 deletions

View File

@@ -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)

View File

@@ -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) |

View File

@@ -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.

View File

@@ -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

View File

@@ -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 | 12801472 | Unreliable | Primary internet transport |
| TCP/IP | IP:port | Stream | Reliable | Requires length-prefix framing |
| UDP/IP | host:port | 12801472 | 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

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

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).

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
// ============================================================================

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();
}
}

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();
}
}