Skip to main content

max / makenotwork

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