1use 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
20const 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 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 #[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 #[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#[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 if matches!(reason, Reason::RateLimited | Reason::Blocked) {
172 pacer
173 .record_refusal(&server.host, &Refusal::Throttled { retry_after: None })
174 .await;
175 }
176 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 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 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 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
231fn 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
239fn 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
246async 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 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
275fn 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 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 #[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 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 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}