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 crate::lookup::resolve::Resolvers;
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    /// @docgen Only the entries a person supplied are their own choice; the bundled table is ours and stays guarded.
39    supplied: std::collections::HashSet<String>,
40}
41
42impl Servers {
43    pub fn bundled() -> Result<Self> {
44        Self::parse(BUNDLED)
45    }
46
47    pub fn parse(text: &str) -> Result<Self> {
48        let file: ServerFile =
49            serde_json::from_str(text).map_err(|source| Error::CatalogMalformed {
50                source: Box::new(source),
51            })?;
52        Ok(Self {
53            by_suffix: file
54                .servers
55                .into_iter()
56                .map(|(suffix, server)| (suffix.to_lowercase(), server))
57                .collect(),
58            supplied: std::collections::HashSet::new(),
59        })
60    }
61
62    pub fn from_file(path: &Path) -> Result<Self> {
63        let text =
64            crate::lookup::read_capped(path, crate::lookup::MAX_TABLE_BYTES).map_err(|source| {
65                Error::FileUnreadable {
66                    path: path.to_path_buf(),
67                    source,
68                }
69            })?;
70        let parsed = Self::parse(&text)?;
71        if parsed.by_suffix.is_empty() {
72            return Err(Error::CatalogEmptySelection);
73        }
74        Ok(parsed)
75    }
76
77    pub fn merge(&mut self, other: Self) {
78        self.supplied.extend(other.by_suffix.keys().cloned());
79        self.by_suffix.extend(other.by_suffix);
80    }
81
82    /// @docgen Lifting the address guard for the whole table would hand a hostile bundled entry the same trust the user meant for their own.
83    #[must_use]
84    pub fn was_supplied(&self, suffix: &str) -> bool {
85        let suffix = suffix.trim_matches('.').to_lowercase();
86        let mut rest = suffix.as_str();
87        loop {
88            if self.by_suffix.contains_key(rest) {
89                return self.supplied.contains(rest);
90            }
91            match rest.split_once('.') {
92                Some((_, tail)) if !tail.is_empty() => rest = tail,
93                _ => return false,
94            }
95        }
96    }
97
98    /// @docgen Matched longest suffix first so a multi-label extension uses its own registry before falling back to the parent.
99    #[must_use]
100    pub fn for_suffix(&self, suffix: &str) -> Option<&Server> {
101        let suffix = suffix.trim_matches('.').to_lowercase();
102        let mut rest = suffix.as_str();
103        loop {
104            if let Some(server) = self.by_suffix.get(rest) {
105                return Some(server);
106            }
107            match rest.split_once('.') {
108                Some((_, tail)) if !tail.is_empty() => rest = tail,
109                _ => return None,
110            }
111        }
112    }
113
114    #[must_use]
115    pub fn len(&self) -> usize {
116        self.by_suffix.len()
117    }
118
119    #[must_use]
120    pub fn is_empty(&self) -> bool {
121        self.by_suffix.is_empty()
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub(crate) enum Verdict {
127    Available { raw: String },
128    Taken { raw: String },
129    Unknown(Reason),
130}
131
132/// @docgen A host IANA or the bundled table named must resolve to a public address; one the user listed is their own choice.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub(crate) enum HostGuard {
135    Enforce,
136    Trusted,
137}
138
139pub(crate) async fn query(
140    resolver: &Resolvers,
141    pacer: &Pacer,
142    server: &Server,
143    domain: &str,
144    timeout: Duration,
145    guard: HostGuard,
146) -> Verdict {
147    let Ok(permit) = pacer.acquire_patiently(&server.host, timeout).await else {
148        return Verdict::Unknown(Reason::RateLimited);
149    };
150
151    let outcome = ask(resolver, &server.host, domain, timeout, guard).await;
152    drop(permit);
153
154    match outcome {
155        Err(reason) => {
156            pacer.record_refusal(&server.host, &Refusal::Dropped).await;
157            Verdict::Unknown(reason)
158        }
159        Ok(raw) => {
160            match verdict::classify(&raw, &server.available_phrase, domain) {
161                TextVerdict::Available => {
162                    pacer.record_success(&server.host).await;
163                    Verdict::Available { raw }
164                }
165                TextVerdict::Taken => {
166                    pacer.record_success(&server.host).await;
167                    Verdict::Taken { raw }
168                }
169                TextVerdict::Unknown(reason) => {
170                    // @docgen Being told to slow down is pushback; any other unreadable answer must not pause the registry.
171                    if matches!(reason, Reason::RateLimited | Reason::Blocked) {
172                        pacer
173                            .record_refusal(&server.host, &Refusal::Throttled { retry_after: None })
174                            .await;
175                    }
176                    // @docgen An answer nobody could read is not a success either, and crediting one ramped the rate up against a server that was struggling.
177                    Verdict::Unknown(reason)
178                }
179            }
180        }
181    }
182}
183
184async fn ask(
185    resolver: &Resolvers,
186    host: &str,
187    domain: &str,
188    timeout: Duration,
189    guard: HostGuard,
190) -> std::result::Result<String, Reason> {
191    // @docgen One deadline covers the whole lookup, because a per-step timeout let a slow server spend the budget three times over.
192    let deadline = deadline_from(timeout);
193
194    let mut stream = connect(resolver, host, deadline, guard).await?;
195    let request = format_request(host, domain);
196
197    tokio::time::timeout_at(deadline, stream.write_all(request.as_bytes()))
198        .await
199        .map_err(|_| Reason::TimedOut)?
200        .map_err(|_| Reason::Unreachable)?;
201    let _ = stream.flush().await;
202
203    let mut buffer = Vec::new();
204    let read = tokio::time::timeout_at(
205        deadline,
206        (&mut stream)
207            .take(MAX_ANSWER_BYTES as u64)
208            .read_to_end(&mut buffer),
209    )
210    .await;
211
212    // @docgen A reply that reached the ceiling is missing its end, and its end is where a refusal is usually written.
213    if buffer.len() >= MAX_ANSWER_BYTES {
214        return Err(Reason::Malformed {
215            detail: "the reply was longer than this tool will read".to_owned(),
216        });
217    }
218
219    match read {
220        Ok(outcome) => answer_from(buffer, outcome.is_err()),
221        // @docgen A server that says its piece then holds the socket open has answered, so the bytes it sent must not be dropped.
222        // @docgen One cut off mid-line has not, and its end is where a refusal is usually written.
223        Err(_) if buffer.last() == Some(&b'\n') => answer_from(buffer, false),
224        Err(_) if !buffer.is_empty() => Err(Reason::Malformed {
225            detail: "the reply stopped in the middle of a line".to_owned(),
226        }),
227        Err(_) => Err(Reason::TimedOut),
228    }
229}
230
231/// @docgen A registry that speaks then hangs up has answered; dropping those bytes turned "quota exceeded" into a dead machine.
232fn answer_from(buffer: Vec<u8>, cut_short: bool) -> std::result::Result<String, Reason> {
233    if cut_short && buffer.is_empty() {
234        return Err(Reason::Unreachable);
235    }
236    Ok(String::from_utf8_lossy(&buffer).replace("\r\n", "\n"))
237}
238
239/// @docgen Adding an unbounded caller-supplied timeout to an Instant panics on overflow.
240fn deadline_from(timeout: Duration) -> tokio::time::Instant {
241    tokio::time::Instant::now()
242        .checked_add(timeout)
243        .unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600))
244}
245
246/// @docgen Resolving and connecting by hand because handing a host and port to the platform resolver is unusable on some targets.
247async fn connect(
248    resolver: &Resolvers,
249    host: &str,
250    deadline: tokio::time::Instant,
251    guard: HostGuard,
252) -> std::result::Result<TcpStream, Reason> {
253    let addresses = tokio::time::timeout_at(deadline, resolver.lookup_ip(host))
254        .await
255        .map_err(|_| Reason::TimedOut)?
256        .map_err(|_| Reason::Unreachable)?;
257
258    let mut last_reason = Reason::Unreachable;
259    for ip in addresses.iter() {
260        // @docgen A public name can answer with an internal address, so the resolved address decides, not the name.
261        if guard == HostGuard::Enforce && !crate::lookup::registry::is_public_ip(ip) {
262            last_reason = Reason::Blocked;
263            continue;
264        }
265        let address = SocketAddr::new(ip, 43);
266        match tokio::time::timeout_at(deadline, TcpStream::connect(address)).await {
267            Ok(Ok(stream)) => return Ok(stream),
268            Ok(Err(_)) => last_reason = Reason::Unreachable,
269            Err(_) => last_reason = Reason::TimedOut,
270        }
271    }
272    Err(last_reason)
273}
274
275/// @docgen Some registries need their own query format on the wire: `domain <name>`, `-T dn <name>`, `<name>/e`.
276fn format_request(host: &str, domain: &str) -> String {
277    let host = host.to_lowercase();
278    if host.contains("verisign-grs") || host.contains("crsnic") || host.contains("internic") {
279        // @docgen A bare name makes these servers do a fuzzy match, so the exact registry record must be asked for.
280        format!("domain {domain}\r\n")
281    } else if host.contains("denic") {
282        format!("-T dn {domain}\r\n")
283    } else if host.contains("jprs") {
284        format!("{domain}/e\r\n")
285    } else if host.contains("dk-hostmaster") || host.contains("arnes.si") {
286        format!("--show-handles {domain}\r\n")
287    } else {
288        format!("{domain}\r\n")
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn servers() -> Servers {
297        Servers::bundled().expect("the bundled table must parse")
298    }
299
300    /// @docgen Austria refuses a fast caller by printing "% Quota exceeded" and resetting, which is an answer, not a dead network.
301    #[test]
302    fn a_reply_cut_short_after_the_registry_spoke_is_still_the_reply() {
303        let spoken = b"% Copyright NIC.AT\r\n%\r\n% Quota exceeded\r\n".to_vec();
304        let answer = answer_from(spoken, true).expect("the registry answered before it hung up");
305        assert!(
306            answer.contains("Quota exceeded"),
307            "the refusal survives the reset: {answer}"
308        );
309        assert!(!answer.contains('\r'), "line endings are still normalised");
310    }
311
312    #[test]
313    fn a_connection_that_carried_nothing_is_still_a_failure_to_reach() {
314        assert_eq!(
315            answer_from(Vec::new(), true).unwrap_err(),
316            Reason::Unreachable,
317            "no bytes means no answer, whatever the socket did"
318        );
319    }
320
321    #[test]
322    fn a_clean_read_is_unaffected() {
323        let body = b"Domain not found.\r\n".to_vec();
324        assert_eq!(
325            answer_from(body, false).expect("a clean read"),
326            "Domain not found.\n"
327        );
328    }
329
330    #[test]
331    fn the_bundled_table_loads_and_is_substantial() {
332        let servers = servers();
333        assert!(!servers.is_empty());
334        assert!(
335            servers.len() > 500,
336            "only {} servers, the table looks truncated",
337            servers.len()
338        );
339    }
340
341    #[test]
342    fn bangladesh_is_covered_at_every_level_it_registers() {
343        let servers = servers();
344        for suffix in ["bd", "com.bd", "net.bd", "org.bd", "co.bd", "ai.bd"] {
345            let server = servers
346                .for_suffix(suffix)
347                .unwrap_or_else(|| panic!(".{suffix} has no server"));
348            assert_eq!(server.host, "whois.get.bd");
349            assert!(
350                !server.available_phrase.is_empty(),
351                ".{suffix} has no available-name phrase"
352            );
353        }
354    }
355
356    #[test]
357    fn the_big_extensions_are_covered() {
358        let servers = servers();
359        for suffix in ["com", "net", "org", "de", "in", "nl", "br"] {
360            assert!(servers.for_suffix(suffix).is_some(), ".{suffix} missing");
361        }
362    }
363
364    #[test]
365    fn an_extension_that_retired_this_protocol_is_absent_by_design() {
366        // @docgen Some registries withdrew their port-43 service, so absence from the table is correct rather than a gap.
367        let servers = servers();
368        assert!(
369            servers.for_suffix("uk").is_none(),
370            "the table should follow the published record rather than keep a dead host"
371        );
372    }
373
374    #[test]
375    fn a_multi_label_suffix_prefers_its_own_registry() {
376        let servers = servers();
377        // @docgen .bd registers at the third level and runs its own server there.
378        let direct = servers
379            .for_suffix("com.bd")
380            .map(|server| server.host.as_str());
381        assert_eq!(direct, Some("whois.get.bd"));
382    }
383
384    #[test]
385    fn an_unknown_suffix_falls_back_to_its_parent() {
386        let servers = servers();
387        let parent = servers.for_suffix("com").map(|server| server.host.clone());
388        let child = servers
389            .for_suffix("nothing-here.com")
390            .map(|server| server.host.clone());
391        assert_eq!(parent, child);
392    }
393
394    #[test]
395    fn a_wholly_unknown_extension_has_no_server() {
396        assert!(servers().for_suffix("zzzz-not-a-real-extension").is_none());
397    }
398
399    #[test]
400    fn registries_that_need_a_special_request_get_one() {
401        assert_eq!(
402            format_request("whois.verisign-grs.com", "x.com"),
403            "domain x.com\r\n"
404        );
405        assert_eq!(format_request("whois.denic.de", "x.de"), "-T dn x.de\r\n");
406        assert_eq!(format_request("whois.jprs.jp", "x.jp"), "x.jp/e\r\n");
407        assert_eq!(format_request("whois.get.bd", "x.bd"), "x.bd\r\n");
408    }
409
410    #[test]
411    fn a_custom_table_overlays_the_bundled_one() {
412        let mut servers = servers();
413        let custom = Servers::parse(
414            r#"{"servers":{"com":{"host":"whois.mine.example","available_phrase":"nothing here"}}}"#,
415        )
416        .unwrap();
417        servers.merge(custom);
418        assert_eq!(
419            servers.for_suffix("com").map(|server| server.host.as_str()),
420            Some("whois.mine.example")
421        );
422    }
423
424    #[test]
425    fn rubbish_is_refused() {
426        assert!(Servers::parse("not json").is_err());
427        assert!(Servers::parse(r#"{"servers":[]}"#).is_err());
428    }
429
430    #[test]
431    fn only_the_entry_a_person_supplied_is_treated_as_their_own_choice() {
432        let mut servers = Servers::bundled().expect("the bundled table parses");
433        let bundled_suffix = servers
434            .by_suffix
435            .keys()
436            .next()
437            .cloned()
438            .expect("the bundled table holds entries");
439        assert!(
440            !servers.was_supplied(&bundled_suffix),
441            "a bundled host is ours and stays guarded"
442        );
443
444        let mine =
445            Servers::parse(r#"{"servers": {"example-zone": {"host": "whois.example.test"}}}"#)
446                .expect("a supplied table parses");
447        servers.merge(mine);
448
449        assert!(
450            servers.was_supplied("example-zone"),
451            "the entry the user wrote is their own choice"
452        );
453        assert!(
454            !servers.was_supplied(&bundled_suffix),
455            "supplying one entry must not lift the guard for the whole table"
456        );
457    }
458}