Skip to main content

max / makenotwork

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