Skip to main content

reserve_core/lookup/
whois.rs

1//! The older text protocol on port 43, the only service many country registries answer on.
2
3use std::collections::HashMap;
4use std::net::SocketAddr;
5use std::path::Path;
6use std::time::Duration;
7
8use hickory_resolver::TokioResolver;
9use serde::Deserialize;
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::TcpStream;
12
13use crate::error::{Error, Result};
14use crate::limit::{Pacer, Refusal};
15use crate::lookup::outcome::Reason;
16use crate::lookup::verdict::{self, TextVerdict};
17
18const BUNDLED: &str = include_str!("../../data/whois-servers.json");
19
20/// @docgen Caps the reply so a hostile or looping server cannot exhaust memory.
21const MAX_ANSWER_BYTES: usize = 256 * 1024;
22
23#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
24pub struct Server {
25    pub host: String,
26    #[serde(default)]
27    pub available_phrase: String,
28}
29
30#[derive(Debug, Deserialize)]
31struct ServerFile {
32    servers: HashMap<String, Server>,
33}
34
35#[derive(Debug, Clone, Default)]
36pub struct Servers {
37    by_suffix: HashMap<String, Server>,
38}
39
40impl Servers {
41    pub fn bundled() -> Result<Self> {
42        Self::parse(BUNDLED)
43    }
44
45    pub fn parse(text: &str) -> Result<Self> {
46        let file: ServerFile =
47            serde_json::from_str(text).map_err(|source| Error::CatalogMalformed {
48                source: Box::new(source),
49            })?;
50        Ok(Self {
51            by_suffix: file
52                .servers
53                .into_iter()
54                .map(|(suffix, server)| (suffix.to_lowercase(), server))
55                .collect(),
56        })
57    }
58
59    pub fn from_file(path: &Path) -> Result<Self> {
60        let text = std::fs::read_to_string(path).map_err(|source| Error::FileUnreadable {
61            path: path.to_path_buf(),
62            source,
63        })?;
64        let parsed = Self::parse(&text)?;
65        if parsed.by_suffix.is_empty() {
66            return Err(Error::CatalogEmptySelection);
67        }
68        Ok(parsed)
69    }
70
71    pub fn merge(&mut self, other: Self) {
72        self.by_suffix.extend(other.by_suffix);
73    }
74
75    /// @docgen Matched longest suffix first so a multi-label extension uses its own registry before falling back to the parent.
76    #[must_use]
77    pub fn for_suffix(&self, suffix: &str) -> Option<&Server> {
78        let suffix = suffix.trim_matches('.').to_lowercase();
79        let mut rest = suffix.as_str();
80        loop {
81            if let Some(server) = self.by_suffix.get(rest) {
82                return Some(server);
83            }
84            match rest.split_once('.') {
85                Some((_, tail)) if !tail.is_empty() => rest = tail,
86                _ => return None,
87            }
88        }
89    }
90
91    #[must_use]
92    pub fn len(&self) -> usize {
93        self.by_suffix.len()
94    }
95
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.by_suffix.is_empty()
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub(crate) enum Verdict {
104    Available,
105    Taken { raw: String },
106    Unknown(Reason),
107}
108
109pub(crate) async fn query(
110    resolver: &TokioResolver,
111    pacer: &Pacer,
112    server: &Server,
113    domain: &str,
114    timeout: Duration,
115) -> Verdict {
116    let Ok(lease) = pacer.acquire_patiently(&server.host, timeout).await else {
117        return Verdict::Unknown(Reason::RateLimited);
118    };
119
120    let outcome = fetch_reply(resolver, &server.host, domain, timeout).await;
121    drop(lease);
122
123    match outcome {
124        Err(reason) => {
125            pacer.record_refusal(&server.host, &Refusal::Dropped).await;
126            Verdict::Unknown(reason)
127        }
128        Ok(raw) => {
129            match verdict::classify(&raw, &server.available_phrase, domain) {
130                TextVerdict::Available => {
131                    pacer.record_success(&server.host).await;
132                    Verdict::Available
133                }
134                TextVerdict::Taken => {
135                    pacer.record_success(&server.host).await;
136                    Verdict::Taken { raw }
137                }
138                TextVerdict::Unknown(reason) => {
139                    // @docgen Being told to slow down is pushback; any other unreadable answer must not pause the registry.
140                    if matches!(reason, Reason::RateLimited | Reason::Blocked) {
141                        pacer
142                            .record_refusal(&server.host, &Refusal::Throttled { retry_after: None })
143                            .await;
144                    } else {
145                        pacer.record_success(&server.host).await;
146                    }
147                    Verdict::Unknown(reason)
148                }
149            }
150        }
151    }
152}
153
154async fn fetch_reply(
155    resolver: &TokioResolver,
156    host: &str,
157    domain: &str,
158    timeout: Duration,
159) -> std::result::Result<String, Reason> {
160    let mut stream = connect(resolver, host, timeout).await?;
161    let request = format_request(host, domain);
162
163    tokio::time::timeout(timeout, stream.write_all(request.as_bytes()))
164        .await
165        .map_err(|_| Reason::TimedOut)?
166        .map_err(|_| Reason::Unreachable)?;
167    let _ = stream.flush().await;
168
169    let mut buffer = Vec::new();
170    let read = tokio::time::timeout(
171        timeout,
172        (&mut stream)
173            .take(MAX_ANSWER_BYTES as u64)
174            .read_to_end(&mut buffer),
175    )
176    .await
177    .map_err(|_| Reason::TimedOut)?;
178    read.map_err(|_| Reason::Unreachable)?;
179
180    Ok(String::from_utf8_lossy(&buffer).replace("\r\n", "\n"))
181}
182
183/// @docgen Resolving and connecting by hand because handing a host and port to the platform resolver is unusable on some targets.
184async fn connect(
185    resolver: &TokioResolver,
186    host: &str,
187    timeout: Duration,
188) -> std::result::Result<TcpStream, Reason> {
189    // @docgen Adding an unbounded caller-supplied timeout to an Instant panics on overflow.
190    let deadline = tokio::time::Instant::now()
191        .checked_add(timeout)
192        .unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600));
193
194    let addresses = tokio::time::timeout_at(deadline, resolver.lookup_ip(host))
195        .await
196        .map_err(|_| Reason::TimedOut)?
197        .map_err(|_| Reason::Unreachable)?;
198
199    let mut last_reason = Reason::Unreachable;
200    for ip in addresses.iter() {
201        let address = SocketAddr::new(ip, 43);
202        match tokio::time::timeout_at(deadline, TcpStream::connect(address)).await {
203            Ok(Ok(stream)) => return Ok(stream),
204            Ok(Err(_)) => last_reason = Reason::Unreachable,
205            Err(_) => last_reason = Reason::TimedOut,
206        }
207    }
208    Err(last_reason)
209}
210
211/// @docgen Some registries need their own query format on the wire: `domain <name>`, `-T dn <name>`, `<name>/e`.
212fn format_request(host: &str, domain: &str) -> String {
213    let host = host.to_lowercase();
214    if host.contains("verisign-grs") || host.contains("crsnic") || host.contains("internic") {
215        // @docgen A bare name makes these servers do a fuzzy match, so the exact registry record must be asked for.
216        format!("domain {domain}\r\n")
217    } else if host.contains("denic") {
218        format!("-T dn {domain}\r\n")
219    } else if host.contains("jprs") {
220        format!("{domain}/e\r\n")
221    } else if host.contains("dk-hostmaster") || host.contains("arnes.si") {
222        format!("--show-handles {domain}\r\n")
223    } else {
224        format!("{domain}\r\n")
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn servers() -> Servers {
233        Servers::bundled().expect("the bundled table must parse")
234    }
235
236    #[test]
237    fn the_bundled_table_loads_and_is_substantial() {
238        let servers = servers();
239        assert!(!servers.is_empty());
240        assert!(
241            servers.len() > 500,
242            "only {} servers, the table looks truncated",
243            servers.len()
244        );
245    }
246
247    #[test]
248    fn bangladesh_is_covered_at_every_level_it_registers() {
249        let servers = servers();
250        for suffix in ["bd", "com.bd", "net.bd", "org.bd", "co.bd", "ai.bd"] {
251            let server = servers
252                .for_suffix(suffix)
253                .unwrap_or_else(|| panic!(".{suffix} has no server"));
254            assert_eq!(server.host, "whois.get.bd");
255            assert!(
256                !server.available_phrase.is_empty(),
257                ".{suffix} has no available-name phrase"
258            );
259        }
260    }
261
262    #[test]
263    fn the_big_extensions_are_covered() {
264        let servers = servers();
265        for suffix in ["com", "net", "org", "de", "in", "nl", "br"] {
266            assert!(servers.for_suffix(suffix).is_some(), ".{suffix} missing");
267        }
268    }
269
270    #[test]
271    fn an_extension_that_retired_this_protocol_is_absent_by_design() {
272        // @docgen Some registries withdrew their port-43 service, so absence from the table is correct rather than a gap.
273        let servers = servers();
274        assert!(
275            servers.for_suffix("uk").is_none(),
276            "the table should follow the published record rather than keep a dead host"
277        );
278    }
279
280    #[test]
281    fn a_multi_label_suffix_prefers_its_own_registry() {
282        let servers = servers();
283        // @docgen .bd registers at the third level and runs its own server there.
284        let direct = servers.for_suffix("com.bd").map(|s| s.host.as_str());
285        assert_eq!(direct, Some("whois.get.bd"));
286    }
287
288    #[test]
289    fn an_unknown_suffix_falls_back_to_its_parent() {
290        let servers = servers();
291        let parent = servers.for_suffix("com").map(|s| s.host.clone());
292        let child = servers
293            .for_suffix("nothing-here.com")
294            .map(|s| s.host.clone());
295        assert_eq!(parent, child);
296    }
297
298    #[test]
299    fn a_wholly_unknown_extension_has_no_server() {
300        assert!(servers().for_suffix("zzzz-not-a-real-extension").is_none());
301    }
302
303    #[test]
304    fn registries_that_need_a_special_request_get_one() {
305        assert_eq!(
306            format_request("whois.verisign-grs.com", "x.com"),
307            "domain x.com\r\n"
308        );
309        assert_eq!(format_request("whois.denic.de", "x.de"), "-T dn x.de\r\n");
310        assert_eq!(format_request("whois.jprs.jp", "x.jp"), "x.jp/e\r\n");
311        assert_eq!(format_request("whois.get.bd", "x.bd"), "x.bd\r\n");
312    }
313
314    #[test]
315    fn a_custom_table_overlays_the_bundled_one() {
316        let mut servers = servers();
317        let custom = Servers::parse(
318            r#"{"servers":{"com":{"host":"whois.mine.example","free":"nothing here"}}}"#,
319        )
320        .unwrap();
321        servers.merge(custom);
322        assert_eq!(
323            servers.for_suffix("com").map(|s| s.host.as_str()),
324            Some("whois.mine.example")
325        );
326    }
327
328    #[test]
329    fn rubbish_is_refused() {
330        assert!(Servers::parse("not json").is_err());
331        assert!(Servers::parse(r#"{"servers":[]}"#).is_err());
332    }
333}