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 = 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 #[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 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
183async fn connect(
185 resolver: &TokioResolver,
186 host: &str,
187 timeout: Duration,
188) -> std::result::Result<TcpStream, Reason> {
189 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
211fn 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 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 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 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}