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 =
61            crate::lookup::read_capped(path, crate::lookup::MAX_TABLE_BYTES).map_err(|source| {
62                Error::FileUnreadable {
63                    path: path.to_path_buf(),
64                    source,
65                }
66            })?;
67        let parsed = Self::parse(&text)?;
68        if parsed.by_suffix.is_empty() {
69            return Err(Error::CatalogEmptySelection);
70        }
71        Ok(parsed)
72    }
73
74    pub fn merge(&mut self, other: Self) {
75        self.by_suffix.extend(other.by_suffix);
76    }
77
78    /// @docgen Matched longest suffix first so a multi-label extension uses its own registry before falling back to the parent.
79    #[must_use]
80    pub fn for_suffix(&self, suffix: &str) -> Option<&Server> {
81        let suffix = suffix.trim_matches('.').to_lowercase();
82        let mut rest = suffix.as_str();
83        loop {
84            if let Some(server) = self.by_suffix.get(rest) {
85                return Some(server);
86            }
87            match rest.split_once('.') {
88                Some((_, tail)) if !tail.is_empty() => rest = tail,
89                _ => return None,
90            }
91        }
92    }
93
94    #[must_use]
95    pub fn len(&self) -> usize {
96        self.by_suffix.len()
97    }
98
99    #[must_use]
100    pub fn is_empty(&self) -> bool {
101        self.by_suffix.is_empty()
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub(crate) enum Verdict {
107    Available,
108    Taken { raw: String },
109    Unknown(Reason),
110}
111
112pub(crate) async fn query(
113    resolver: &TokioResolver,
114    pacer: &Pacer,
115    server: &Server,
116    domain: &str,
117    timeout: Duration,
118) -> Verdict {
119    let Ok(lease) = pacer.acquire_patiently(&server.host, timeout).await else {
120        return Verdict::Unknown(Reason::RateLimited);
121    };
122
123    let outcome = fetch_reply(resolver, &server.host, domain, timeout).await;
124    drop(lease);
125
126    match outcome {
127        Err(reason) => {
128            pacer.record_refusal(&server.host, &Refusal::Dropped).await;
129            Verdict::Unknown(reason)
130        }
131        Ok(raw) => {
132            match verdict::classify(&raw, &server.available_phrase, domain) {
133                TextVerdict::Available => {
134                    pacer.record_success(&server.host).await;
135                    Verdict::Available
136                }
137                TextVerdict::Taken => {
138                    pacer.record_success(&server.host).await;
139                    Verdict::Taken { raw }
140                }
141                TextVerdict::Unknown(reason) => {
142                    // @docgen Being told to slow down is pushback; any other unreadable answer must not pause the registry.
143                    if matches!(reason, Reason::RateLimited | Reason::Blocked) {
144                        pacer
145                            .record_refusal(&server.host, &Refusal::Throttled { retry_after: None })
146                            .await;
147                    } else {
148                        pacer.record_success(&server.host).await;
149                    }
150                    Verdict::Unknown(reason)
151                }
152            }
153        }
154    }
155}
156
157async fn fetch_reply(
158    resolver: &TokioResolver,
159    host: &str,
160    domain: &str,
161    timeout: Duration,
162) -> std::result::Result<String, Reason> {
163    // @docgen One deadline covers the whole lookup, because a per-step timeout let a slow server spend the budget three times over.
164    let deadline = deadline_from(timeout);
165
166    let mut stream = connect(resolver, host, deadline).await?;
167    let request = format_request(host, domain);
168
169    tokio::time::timeout_at(deadline, stream.write_all(request.as_bytes()))
170        .await
171        .map_err(|_| Reason::TimedOut)?
172        .map_err(|_| Reason::Unreachable)?;
173    let _ = stream.flush().await;
174
175    let mut buffer = Vec::new();
176    let read = tokio::time::timeout_at(
177        deadline,
178        (&mut stream)
179            .take(MAX_ANSWER_BYTES as u64)
180            .read_to_end(&mut buffer),
181    )
182    .await
183    .map_err(|_| Reason::TimedOut)?;
184
185    answer_from(buffer, read.is_err())
186}
187
188/// @docgen A registry that says its piece and then hangs up has answered. Throwing those bytes away turned "quota exceeded" into "could not connect", which then read as the whole machine being offline.
189fn answer_from(buffer: Vec<u8>, cut_short: bool) -> std::result::Result<String, Reason> {
190    if cut_short && buffer.is_empty() {
191        return Err(Reason::Unreachable);
192    }
193    Ok(String::from_utf8_lossy(&buffer).replace("\r\n", "\n"))
194}
195
196/// @docgen Adding an unbounded caller-supplied timeout to an Instant panics on overflow.
197fn deadline_from(timeout: Duration) -> tokio::time::Instant {
198    tokio::time::Instant::now()
199        .checked_add(timeout)
200        .unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600))
201}
202
203/// @docgen Resolving and connecting by hand because handing a host and port to the platform resolver is unusable on some targets.
204async fn connect(
205    resolver: &TokioResolver,
206    host: &str,
207    deadline: tokio::time::Instant,
208) -> std::result::Result<TcpStream, Reason> {
209    let addresses = tokio::time::timeout_at(deadline, resolver.lookup_ip(host))
210        .await
211        .map_err(|_| Reason::TimedOut)?
212        .map_err(|_| Reason::Unreachable)?;
213
214    let mut last_reason = Reason::Unreachable;
215    for ip in addresses.iter() {
216        let address = SocketAddr::new(ip, 43);
217        match tokio::time::timeout_at(deadline, TcpStream::connect(address)).await {
218            Ok(Ok(stream)) => return Ok(stream),
219            Ok(Err(_)) => last_reason = Reason::Unreachable,
220            Err(_) => last_reason = Reason::TimedOut,
221        }
222    }
223    Err(last_reason)
224}
225
226/// @docgen Some registries need their own query format on the wire: `domain <name>`, `-T dn <name>`, `<name>/e`.
227fn format_request(host: &str, domain: &str) -> String {
228    let host = host.to_lowercase();
229    if host.contains("verisign-grs") || host.contains("crsnic") || host.contains("internic") {
230        // @docgen A bare name makes these servers do a fuzzy match, so the exact registry record must be asked for.
231        format!("domain {domain}\r\n")
232    } else if host.contains("denic") {
233        format!("-T dn {domain}\r\n")
234    } else if host.contains("jprs") {
235        format!("{domain}/e\r\n")
236    } else if host.contains("dk-hostmaster") || host.contains("arnes.si") {
237        format!("--show-handles {domain}\r\n")
238    } else {
239        format!("{domain}\r\n")
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn servers() -> Servers {
248        Servers::bundled().expect("the bundled table must parse")
249    }
250
251    /// @docgen Austria refuses a fast caller by printing "% Quota exceeded" and resetting the connection, which is an answer and must not read as a dead network.
252    #[test]
253    fn a_reply_cut_short_after_the_registry_spoke_is_still_the_reply() {
254        let spoken = b"% Copyright NIC.AT\r\n%\r\n% Quota exceeded\r\n".to_vec();
255        let answer = answer_from(spoken, true).expect("the registry answered before it hung up");
256        assert!(
257            answer.contains("Quota exceeded"),
258            "the refusal survives the reset: {answer}"
259        );
260        assert!(!answer.contains('\r'), "line endings are still normalised");
261    }
262
263    #[test]
264    fn a_connection_that_carried_nothing_is_still_a_failure_to_reach() {
265        assert_eq!(
266            answer_from(Vec::new(), true).unwrap_err(),
267            Reason::Unreachable,
268            "no bytes means no answer, whatever the socket did"
269        );
270    }
271
272    #[test]
273    fn a_clean_read_is_unaffected() {
274        let body = b"Domain not found.\r\n".to_vec();
275        assert_eq!(
276            answer_from(body, false).expect("a clean read"),
277            "Domain not found.\n"
278        );
279    }
280
281    #[test]
282    fn the_bundled_table_loads_and_is_substantial() {
283        let servers = servers();
284        assert!(!servers.is_empty());
285        assert!(
286            servers.len() > 500,
287            "only {} servers, the table looks truncated",
288            servers.len()
289        );
290    }
291
292    #[test]
293    fn bangladesh_is_covered_at_every_level_it_registers() {
294        let servers = servers();
295        for suffix in ["bd", "com.bd", "net.bd", "org.bd", "co.bd", "ai.bd"] {
296            let server = servers
297                .for_suffix(suffix)
298                .unwrap_or_else(|| panic!(".{suffix} has no server"));
299            assert_eq!(server.host, "whois.get.bd");
300            assert!(
301                !server.available_phrase.is_empty(),
302                ".{suffix} has no available-name phrase"
303            );
304        }
305    }
306
307    #[test]
308    fn the_big_extensions_are_covered() {
309        let servers = servers();
310        for suffix in ["com", "net", "org", "de", "in", "nl", "br"] {
311            assert!(servers.for_suffix(suffix).is_some(), ".{suffix} missing");
312        }
313    }
314
315    #[test]
316    fn an_extension_that_retired_this_protocol_is_absent_by_design() {
317        // @docgen Some registries withdrew their port-43 service, so absence from the table is correct rather than a gap.
318        let servers = servers();
319        assert!(
320            servers.for_suffix("uk").is_none(),
321            "the table should follow the published record rather than keep a dead host"
322        );
323    }
324
325    #[test]
326    fn a_multi_label_suffix_prefers_its_own_registry() {
327        let servers = servers();
328        // @docgen .bd registers at the third level and runs its own server there.
329        let direct = servers.for_suffix("com.bd").map(|s| s.host.as_str());
330        assert_eq!(direct, Some("whois.get.bd"));
331    }
332
333    #[test]
334    fn an_unknown_suffix_falls_back_to_its_parent() {
335        let servers = servers();
336        let parent = servers.for_suffix("com").map(|s| s.host.clone());
337        let child = servers
338            .for_suffix("nothing-here.com")
339            .map(|s| s.host.clone());
340        assert_eq!(parent, child);
341    }
342
343    #[test]
344    fn a_wholly_unknown_extension_has_no_server() {
345        assert!(servers().for_suffix("zzzz-not-a-real-extension").is_none());
346    }
347
348    #[test]
349    fn registries_that_need_a_special_request_get_one() {
350        assert_eq!(
351            format_request("whois.verisign-grs.com", "x.com"),
352            "domain x.com\r\n"
353        );
354        assert_eq!(format_request("whois.denic.de", "x.de"), "-T dn x.de\r\n");
355        assert_eq!(format_request("whois.jprs.jp", "x.jp"), "x.jp/e\r\n");
356        assert_eq!(format_request("whois.get.bd", "x.bd"), "x.bd\r\n");
357    }
358
359    #[test]
360    fn a_custom_table_overlays_the_bundled_one() {
361        let mut servers = servers();
362        let custom = Servers::parse(
363            r#"{"servers":{"com":{"host":"whois.mine.example","free":"nothing here"}}}"#,
364        )
365        .unwrap();
366        servers.merge(custom);
367        assert_eq!(
368            servers.for_suffix("com").map(|s| s.host.as_str()),
369            Some("whois.mine.example")
370        );
371    }
372
373    #[test]
374    fn rubbish_is_refused() {
375        assert!(Servers::parse("not json").is_err());
376        assert!(Servers::parse(r#"{"servers":[]}"#).is_err());
377    }
378}