1use 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
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}
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 #[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 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 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
188fn 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
196fn 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
203async 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
226fn 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 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 #[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 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 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}