Skip to main content

stratum_apps/network_helpers/
resolve_hostname.rs

1use std::{
2    net::{IpAddr, SocketAddr},
3    time::Duration,
4};
5
6use tracing::{debug, info};
7
8/// Maximum time to wait for a DNS lookup before giving up.
9/// DNS resolution should complete in milliseconds on a healthy network;
10/// 5 seconds is generous enough for slow links while still failing fast
11/// when DNS is broken.
12const DNS_TIMEOUT: Duration = Duration::from_secs(5);
13
14/// Errors that can occur during address resolution.
15#[derive(Debug)]
16pub enum ResolveError {
17    /// DNS lookup returned no results for the given hostname.
18    NoResults(String),
19    /// DNS lookup failed with an IO error.
20    LookupFailed(std::io::Error),
21    /// DNS lookup did not complete within the timeout.
22    Timeout(String),
23}
24
25impl std::fmt::Display for ResolveError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            ResolveError::NoResults(host) => {
29                write!(f, "DNS resolution returned no results for '{host}'")
30            }
31            ResolveError::LookupFailed(e) => write!(f, "DNS resolution failed: {e}"),
32            ResolveError::Timeout(host) => {
33                write!(
34                    f,
35                    "DNS resolution for '{host}' timed out after {}s",
36                    DNS_TIMEOUT.as_secs()
37                )
38            }
39        }
40    }
41}
42
43impl std::error::Error for ResolveError {}
44
45/// Resolves a host string and port to a [`SocketAddr`].
46///
47/// This function first attempts to parse the host as an IP address (fast path, no DNS).
48/// If that fails, it performs an async DNS lookup via [`tokio::net::lookup_host`].
49///
50/// This should be called at connection time (not config parse time) so that DNS changes
51/// are picked up on reconnection attempts.
52///
53/// # Examples
54///
55/// ```ignore
56/// // IP address (fast path)
57/// let addr = resolve_host("127.0.0.1", 3333).await?;
58///
59/// // Hostname (DNS lookup)
60/// let addr = resolve_host("pool.example.com", 3333).await?;
61/// ```
62pub async fn resolve_host(host: &str, port: u16) -> Result<SocketAddr, ResolveError> {
63    // Fast path: try parsing as an IP address directly (no DNS needed)
64    if let Ok(ip) = host.parse::<IpAddr>() {
65        return Ok(SocketAddr::new(ip, port));
66    }
67
68    // Slow path: perform async DNS resolution
69    info!("Resolving hostname '{host}' via DNS...");
70    let lookup = format!("{host}:{port}");
71    let addr = tokio::time::timeout(DNS_TIMEOUT, tokio::net::lookup_host(&lookup))
72        .await
73        .map_err(|_| ResolveError::Timeout(host.to_string()))?
74        .map_err(ResolveError::LookupFailed)?
75        // DNS can return multiple addresses; take the first one
76        .next()
77        .ok_or_else(|| ResolveError::NoResults(host.to_string()))?;
78
79    debug!("Resolved '{host}' -> {addr}");
80    Ok(addr)
81}
82
83/// Resolves a `"host:port"` string to a [`SocketAddr`].
84///
85/// Accepts both IP addresses and hostnames in the `"host:port"` format.
86/// For hostnames, performs async DNS resolution via [`tokio::net::lookup_host`].
87///
88/// # Examples
89///
90/// ```ignore
91/// // IP address (fast path)
92/// let addr = resolve_host_port("127.0.0.1:3333").await?;
93///
94/// // Hostname (DNS lookup)
95/// let addr = resolve_host_port("pool.example.com:3333").await?;
96/// ```
97pub async fn resolve_host_port(addr: &str) -> Result<SocketAddr, ResolveError> {
98    // Fast path: try parsing as a SocketAddr directly (no DNS needed)
99    if let Ok(socket) = addr.parse::<SocketAddr>() {
100        return Ok(socket);
101    }
102
103    // Slow path: perform async DNS resolution
104    info!("Resolving address '{addr}' via DNS...");
105    let resolved = tokio::time::timeout(DNS_TIMEOUT, tokio::net::lookup_host(addr))
106        .await
107        .map_err(|_| ResolveError::Timeout(addr.to_string()))?
108        .map_err(ResolveError::LookupFailed)?
109        // DNS can return multiple addresses; take the first one
110        .next()
111        .ok_or_else(|| ResolveError::NoResults(addr.to_string()))?;
112
113    debug!("Resolved '{addr}' -> {resolved}");
114    Ok(resolved)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[tokio::test]
122    async fn resolve_ipv4_address() {
123        let addr = resolve_host("127.0.0.1", 3333).await.unwrap();
124        assert_eq!(addr, SocketAddr::new("127.0.0.1".parse().unwrap(), 3333));
125    }
126
127    #[tokio::test]
128    async fn resolve_ipv6_address() {
129        let addr = resolve_host("::1", 3333).await.unwrap();
130        assert_eq!(addr, SocketAddr::new("::1".parse().unwrap(), 3333));
131    }
132
133    #[tokio::test]
134    async fn resolve_localhost_hostname() {
135        let addr = resolve_host("localhost", 3333).await.unwrap();
136        // localhost can resolve to either 127.0.0.1 or ::1 depending on the system
137        assert_eq!(addr.port(), 3333);
138        assert!(addr.ip().is_loopback());
139    }
140
141    #[tokio::test]
142    async fn resolve_invalid_hostname_fails() {
143        let result = resolve_host("this.hostname.definitely.does.not.exist.invalid", 3333).await;
144        assert!(result.is_err());
145    }
146
147    #[tokio::test]
148    async fn resolve_host_port_ipv4() {
149        let addr = resolve_host_port("127.0.0.1:3333").await.unwrap();
150        assert_eq!(addr, SocketAddr::new("127.0.0.1".parse().unwrap(), 3333));
151    }
152
153    #[tokio::test]
154    async fn resolve_host_port_localhost() {
155        let addr = resolve_host_port("localhost:3333").await.unwrap();
156        assert_eq!(addr.port(), 3333);
157        assert!(addr.ip().is_loopback());
158    }
159
160    #[tokio::test]
161    async fn resolve_host_port_invalid_fails() {
162        let result =
163            resolve_host_port("this.hostname.definitely.does.not.exist.invalid:3333").await;
164        assert!(result.is_err());
165    }
166}