Skip to main content

max / makenotwork

15.5 KB · 471 lines History Blame Raw
1 //! Domain registration checking: RDAP first, raw port-43 WHOIS as fallback.
2 //!
3 //! RDAP is the protocol registries are actually maintaining, so it is tried
4 //! first for every domain (see [`super::rdap`]). WHOIS remains for the TLDs
5 //! with no RDAP service, `.io` and `.me` among them.
6
7 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8 use tokio::net::TcpStream;
9 use tracing::instrument;
10
11 use crate::config::WhoisConfig;
12 use crate::types::WhoisResult;
13
14 use super::rdap;
15
16 /// Query domain registration info, over RDAP where the TLD supports it.
17 #[instrument(skip_all)]
18 pub async fn check_whois(target: &str, config: &WhoisConfig) -> WhoisResult {
19 match rdap::lookup(&config.domain).await {
20 Ok(Some(domain)) => {
21 return build_result(
22 target,
23 config,
24 domain.registrar,
25 domain.expiry_date,
26 domain.nameservers,
27 );
28 }
29 Ok(None) => {}
30 Err(e) => {
31 // An RDAP service exists but did not answer. WHOIS is worth a try
32 // before calling the domain unverifiable.
33 tracing::warn!(
34 "{target}: RDAP lookup for {} failed, falling back to WHOIS: {e}",
35 config.domain
36 );
37 }
38 }
39
40 let Some(server) = whois_server_for_tld(&config.domain) else {
41 return error_result(
42 target,
43 config,
44 format!(
45 "no RDAP service or WHOIS server known for {}",
46 config.domain
47 ),
48 );
49 };
50
51 match query_whois(server, &config.domain).await {
52 Ok(response) => {
53 let parsed = parse_whois_response(&response);
54 build_result(
55 target,
56 config,
57 parsed.registrar,
58 parsed.expiry_date,
59 parsed.nameservers,
60 )
61 }
62 Err(e) => error_result(target, config, e),
63 }
64 }
65
66 fn build_result(
67 target: &str,
68 config: &WhoisConfig,
69 registrar: Option<String>,
70 expiry_date: Option<String>,
71 nameservers: Vec<String>,
72 ) -> WhoisResult {
73 let days_remaining = expiry_date.as_deref().and_then(compute_days_remaining);
74
75 WhoisResult {
76 target: target.to_string(),
77 domain: config.domain.clone(),
78 registrar,
79 expiry_date,
80 days_remaining,
81 nameservers,
82 checked_at: chrono::Utc::now().to_rfc3339(),
83 error: None,
84 }
85 }
86
87 fn error_result(target: &str, config: &WhoisConfig, error: String) -> WhoisResult {
88 WhoisResult {
89 target: target.to_string(),
90 domain: config.domain.clone(),
91 registrar: None,
92 expiry_date: None,
93 days_remaining: None,
94 nameservers: vec![],
95 checked_at: chrono::Utc::now().to_rfc3339(),
96 error: Some(error),
97 }
98 }
99
100 /// Determine the port-43 WHOIS server for a domain based on its TLD.
101 ///
102 /// Only TLDs whose WHOIS server is still answering belong here. `.app` and
103 /// `.dev` used to map to `whois.nic.google`, which Google Registry retired in
104 /// favour of RDAP; the hostname is now NXDOMAIN, so those two are RDAP-only.
105 pub fn whois_server_for_tld(domain: &str) -> Option<&'static str> {
106 let tld = domain.rsplit('.').next()?;
107 match tld {
108 "com" => Some("whois.verisign-grs.com"),
109 "net" => Some("whois.verisign-grs.com"),
110 "org" => Some("whois.pir.org"),
111 "work" => Some("whois.nic.work"),
112 "io" => Some("whois.nic.io"),
113 "me" => Some("whois.nic.me"),
114 "info" => Some("whois.afilias.net"),
115 _ => None,
116 }
117 }
118
119 /// Send a WHOIS query over TCP and return the raw response.
120 async fn query_whois(server: &str, domain: &str) -> Result<String, String> {
121 let addr = format!("{server}:43");
122
123 let mut stream = tokio::time::timeout(
124 std::time::Duration::from_secs(10),
125 TcpStream::connect(&addr),
126 )
127 .await
128 .map_err(|_| format!("WHOIS connection to {server} timed out"))?
129 .map_err(|e| format!("WHOIS connection to {server} failed: {e}"))?;
130
131 stream
132 .write_all(format!("{domain}\r\n").as_bytes())
133 .await
134 .map_err(|e| format!("WHOIS write failed: {e}"))?;
135
136 let mut response = Vec::with_capacity(4096);
137 let mut buf = [0u8; 4096];
138 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
139 loop {
140 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
141 if remaining.is_zero() {
142 break;
143 }
144 match tokio::time::timeout(remaining, stream.read(&mut buf)).await {
145 Ok(Ok(0)) => break,
146 Ok(Ok(n)) => {
147 response.extend_from_slice(&buf[..n]);
148 if response.len() > 65536 {
149 break;
150 }
151 }
152 Ok(Err(e)) => return Err(format!("WHOIS read error: {e}")),
153 Err(_) => break,
154 }
155 }
156
157 String::from_utf8(response).map_err(|e| format!("WHOIS response not UTF-8: {e}"))
158 }
159
160 /// Parse key fields from a raw WHOIS response.
161 pub fn parse_whois_response(response: &str) -> ParsedWhoisResult {
162 let mut registrar = None;
163 let mut expiry_date = None;
164 let mut nameservers = Vec::new();
165
166 for line in response.lines() {
167 let line = line.trim();
168 let lower = line.to_lowercase();
169
170 // Registrar
171 if registrar.is_none() && lower.starts_with("registrar:") {
172 registrar = extract_value(line);
173 }
174
175 // Expiry date, multiple possible field names
176 if expiry_date.is_none()
177 && (lower.starts_with("registry expiry date:")
178 || lower.starts_with("registrar registration expiration date:")
179 || lower.starts_with("expiration date:")
180 || lower.starts_with("paid-till:"))
181 {
182 expiry_date = extract_value(line);
183 }
184
185 // Name servers
186 if (lower.starts_with("name server:") || lower.starts_with("nserver:"))
187 && let Some(ns) = extract_value(line)
188 {
189 let ns = ns.trim_end_matches('.').to_lowercase();
190 if !nameservers.contains(&ns) {
191 nameservers.push(ns);
192 }
193 }
194 }
195
196 ParsedWhoisResult {
197 registrar,
198 expiry_date,
199 nameservers,
200 }
201 }
202
203 pub struct ParsedWhoisResult {
204 pub registrar: Option<String>,
205 pub expiry_date: Option<String>,
206 pub nameservers: Vec<String>,
207 }
208
209 fn extract_value(line: &str) -> Option<String> {
210 let value = line.split_once(':')?.1.trim();
211 if value.is_empty() {
212 None
213 } else {
214 Some(value.to_string())
215 }
216 }
217
218 /// Compute days remaining from an expiry date string.
219 /// Tries RFC 3339 first, then common date-only formats.
220 pub fn compute_days_remaining(expiry_str: &str) -> Option<i64> {
221 let expiry = chrono::DateTime::parse_from_rfc3339(expiry_str)
222 .map(|dt| dt.with_timezone(&chrono::Utc))
223 .or_else(|_| {
224 chrono::NaiveDate::parse_from_str(expiry_str.trim(), "%Y-%m-%d")
225 .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc())
226 })
227 .ok()?;
228
229 let now = chrono::Utc::now();
230 Some(expiry.signed_duration_since(now).num_days())
231 }
232
233 #[cfg(test)]
234 mod tests {
235 use super::*;
236
237 #[test]
238 fn whois_server_known_tlds() {
239 assert_eq!(whois_server_for_tld("example.work"), Some("whois.nic.work"));
240 assert_eq!(
241 whois_server_for_tld("example.com"),
242 Some("whois.verisign-grs.com")
243 );
244 assert_eq!(
245 whois_server_for_tld("example.net"),
246 Some("whois.verisign-grs.com")
247 );
248 assert_eq!(whois_server_for_tld("example.org"), Some("whois.pir.org"));
249 }
250
251 #[test]
252 fn whois_server_unknown_tld() {
253 assert_eq!(whois_server_for_tld("example.xyz"), None);
254 }
255
256 #[test]
257 fn whois_server_omits_retired_google_registry() {
258 // `whois.nic.google` is NXDOMAIN since Google Registry moved to RDAP.
259 // Mapping .app/.dev to it produced a permanent degraded state on the
260 // htpy target; these two must stay RDAP-only.
261 assert_eq!(whois_server_for_tld("htpy.app"), None);
262 assert_eq!(whois_server_for_tld("example.dev"), None);
263 }
264
265 #[test]
266 fn parse_whois_verisign_response() {
267 let response = r"
268 Domain Name: EXAMPLE.COM
269 Registry Domain ID: 2336799_DOMAIN_COM-VRSN
270 Registrar WHOIS Server: whois.registrar.com
271 Registrar URL: http://www.registrar.com
272 Updated Date: 2024-08-14T07:01:44Z
273 Creation Date: 1995-08-14T04:00:00Z
274 Registry Expiry Date: 2025-08-13T04:00:00Z
275 Registrar: Example Registrar, Inc.
276 Name Server: A.IANA-SERVERS.NET
277 Name Server: B.IANA-SERVERS.NET
278 ";
279 let parsed = parse_whois_response(response);
280 assert_eq!(parsed.registrar.as_deref(), Some("Example Registrar, Inc."));
281 assert_eq!(parsed.expiry_date.as_deref(), Some("2025-08-13T04:00:00Z"));
282 assert_eq!(parsed.nameservers.len(), 2);
283 assert!(
284 parsed
285 .nameservers
286 .contains(&"a.iana-servers.net".to_string())
287 );
288 assert!(
289 parsed
290 .nameservers
291 .contains(&"b.iana-servers.net".to_string())
292 );
293 }
294
295 #[test]
296 fn parse_whois_nic_work_response() {
297 let response = r"
298 Domain Name: makenot.work
299 Registry Domain ID: abc123
300 Registrar WHOIS Server: whois.namecheap.com
301 Registrar URL: http://www.namecheap.com
302 Updated Date: 2025-03-01T12:00:00Z
303 Creation Date: 2024-03-01T12:00:00Z
304 Registry Expiry Date: 2026-12-01T12:00:00Z
305 Registrar: Namecheap, Inc.
306 Name Server: dns1.registrar-servers.com
307 Name Server: dns2.registrar-servers.com
308 ";
309 let parsed = parse_whois_response(response);
310 assert_eq!(parsed.registrar.as_deref(), Some("Namecheap, Inc."));
311 assert_eq!(parsed.expiry_date.as_deref(), Some("2026-12-01T12:00:00Z"));
312 assert_eq!(parsed.nameservers.len(), 2);
313 }
314
315 #[test]
316 fn parse_whois_empty_response() {
317 let parsed = parse_whois_response("");
318 assert!(parsed.registrar.is_none());
319 assert!(parsed.expiry_date.is_none());
320 assert!(parsed.nameservers.is_empty());
321 }
322
323 #[test]
324 fn parse_whois_no_matching_fields() {
325 let parsed = parse_whois_response("Some random text\nAnother line\n");
326 assert!(parsed.registrar.is_none());
327 assert!(parsed.expiry_date.is_none());
328 assert!(parsed.nameservers.is_empty());
329 }
330
331 #[test]
332 fn compute_days_remaining_rfc3339() {
333 let future = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
334 let days = compute_days_remaining(&future).unwrap();
335 assert!((29..=30).contains(&days));
336 }
337
338 #[test]
339 fn compute_days_remaining_date_only() {
340 let future = (chrono::Utc::now() + chrono::Duration::days(60))
341 .format("%Y-%m-%d")
342 .to_string();
343 let days = compute_days_remaining(&future).unwrap();
344 assert!((59..=60).contains(&days));
345 }
346
347 #[test]
348 fn compute_days_remaining_expired() {
349 let past = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
350 let days = compute_days_remaining(&past).unwrap();
351 assert!(days < 0);
352 }
353
354 #[test]
355 fn compute_days_remaining_invalid() {
356 assert!(compute_days_remaining("not-a-date").is_none());
357 }
358
359 #[test]
360 fn parse_whois_alternative_expiry_field() {
361 let response = "Expiration Date: 2027-06-15T00:00:00Z\nName Server: ns1.example.com\n";
362 let parsed = parse_whois_response(response);
363 assert_eq!(parsed.expiry_date.as_deref(), Some("2027-06-15T00:00:00Z"));
364 }
365
366 #[test]
367 fn parse_whois_deduplicates_nameservers() {
368 let response = "Name Server: ns1.example.com\nName Server: ns1.example.com\n";
369 let parsed = parse_whois_response(response);
370 assert_eq!(parsed.nameservers.len(), 1);
371 }
372
373 // extended TLD coverage
374
375 #[test]
376 fn whois_server_all_known_tlds() {
377 // Pins every match arm, a mutation swapping or removing an arm would
378 // surface as Some→None or wrong host.
379 assert_eq!(whois_server_for_tld("x.io"), Some("whois.nic.io"));
380 assert_eq!(whois_server_for_tld("x.me"), Some("whois.nic.me"));
381 assert_eq!(whois_server_for_tld("x.info"), Some("whois.afilias.net"));
382 }
383
384 #[test]
385 fn whois_server_empty_domain_is_none() {
386 // `rsplit('.').next()` on "" returns Some(""), which falls through to None.
387 assert_eq!(whois_server_for_tld(""), None);
388 }
389
390 // first-match semantics (`is_none()` guards)
391
392 #[test]
393 fn parse_whois_takes_first_registrar_only() {
394 // Pins `if registrar.is_none() && ...`: a duplicate Registrar line
395 // must NOT overwrite the first value.
396 let response = "Registrar: First, Inc.\nRegistrar: Second, LLC.\n";
397 let parsed = parse_whois_response(response);
398 assert_eq!(parsed.registrar.as_deref(), Some("First, Inc."));
399 }
400
401 #[test]
402 fn parse_whois_takes_first_expiry_only() {
403 let response = "Registry Expiry Date: 2025-01-01T00:00:00Z\n\
404 Registry Expiry Date: 2099-12-31T00:00:00Z\n";
405 let parsed = parse_whois_response(response);
406 assert_eq!(parsed.expiry_date.as_deref(), Some("2025-01-01T00:00:00Z"));
407 }
408
409 // remaining alternative field names
410
411 #[test]
412 fn parse_whois_registrar_registration_expiration_date() {
413 let response = "Registrar Registration Expiration Date: 2028-01-15T12:00:00Z\n";
414 let parsed = parse_whois_response(response);
415 assert_eq!(parsed.expiry_date.as_deref(), Some("2028-01-15T12:00:00Z"));
416 }
417
418 #[test]
419 fn parse_whois_paid_till() {
420 // Russian/Eastern European registry format
421 let response = "paid-till: 2029-03-20T00:00:00Z\n";
422 let parsed = parse_whois_response(response);
423 assert_eq!(parsed.expiry_date.as_deref(), Some("2029-03-20T00:00:00Z"));
424 }
425
426 #[test]
427 fn parse_whois_nserver_alias() {
428 // Some registries use `nserver:` instead of `Name Server:`.
429 let response = "nserver: ns1.example.org\nnserver: ns2.example.org\n";
430 let parsed = parse_whois_response(response);
431 assert_eq!(parsed.nameservers.len(), 2);
432 assert!(parsed.nameservers.contains(&"ns1.example.org".to_string()));
433 }
434
435 // normalization on nameservers
436
437 #[test]
438 fn parse_whois_strips_trailing_dot_from_nameservers() {
439 let response = "Name Server: NS1.EXAMPLE.COM.\n";
440 let parsed = parse_whois_response(response);
441 assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]);
442 }
443
444 #[test]
445 fn parse_whois_lowercases_nameservers() {
446 // Catches a mutation removing `.to_lowercase()`.
447 let response = "Name Server: NS1.EXAMPLE.COM\nNAME SERVER: ns1.example.com\n";
448 let parsed = parse_whois_response(response);
449 // Both should normalise to the same value; dedup leaves 1.
450 assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]);
451 }
452
453 // compute_days_remaining timezone/format edges
454
455 #[test]
456 fn compute_days_remaining_trims_whitespace_around_date_only() {
457 // Pins the `.trim()` call inside the date-only fallback.
458 let future = (chrono::Utc::now() + chrono::Duration::days(45))
459 .format(" %Y-%m-%d ")
460 .to_string();
461 let days = compute_days_remaining(&future).unwrap();
462 assert!((44..=45).contains(&days), "expected 44 or 45, got {days}");
463 }
464
465 #[test]
466 fn compute_days_remaining_garbage_after_rfc3339_is_none() {
467 // Neither format parses; falls through to None.
468 assert!(compute_days_remaining("2027-06-15 NOT A TIME").is_none());
469 }
470 }
471