1use std::collections::BTreeMap;
2use std::net::IpAddr;
3
4use chrono::{Local, Utc};
5use futures::StreamExt;
6
7use crate::config::Config;
8use crate::discover::{ct, dns, fetch, probe_cache, resolve};
9use crate::model::{
10 CtStats, DnsRef, DnsStatus, DomainState, Endpoint, Host, Origins, PortScan, ProbeStatus,
11 ScanState,
12};
13
14const TLS_PORT: u16 = 443;
15
16#[derive(Debug, Clone, Default)]
17pub struct Options {
18 pub refresh: bool,
19 pub probe: bool,
20 pub ports: Option<Vec<u16>>,
21}
22
23#[derive(Debug, thiserror::Error)]
24pub enum Error {
25 #[error(transparent)]
26 Ct(#[from] fetch::Error),
27 #[error("DNS resolver unavailable: {0}")]
28 Resolver(String),
29 #[error("task join failed")]
30 Join,
31}
32
33pub async fn collect(domain: &str, cfg: &Config, opts: &Options) -> Result<DomainState, Error> {
34 let outcome = fetch::obtain(domain, cfg, opts.refresh).await?;
36 let certs = outcome.certs;
37 let ct_stats = ct_stats(&certs);
38
39 let resolver = dns::build_resolver(cfg).map_err(|e| Error::Resolver(e.to_string()))?;
41 let discovery = dns::discover(domain, &resolver).await;
42
43 let mut hosts: BTreeMap<String, Host> = BTreeMap::new();
45 for cert in certs.values().filter(|c| c.currently_valid) {
46 for name in &cert.identities {
47 if !ct::is_wildcard(name) {
48 let host = hosts.entry(name.clone()).or_default();
49 host.name = name.clone();
50 host.origins.insert(Origins::CT);
51 }
52 }
53 }
54 let apex = hosts.entry(domain.to_string()).or_default();
55 apex.name = domain.to_string();
56 apex.origins.insert(Origins::APEX);
57 for rec in &discovery.srv {
58 if dns::in_domain(&rec.target, domain) {
59 let host = hosts.entry(rec.target.clone()).or_default();
60 host.name = rec.target.clone();
61 host.origins.insert(Origins::DNS);
62 host.dns_refs.push(DnsRef::Srv {
63 service: rec.service.clone(),
64 });
65 }
66 }
67 for rec in &discovery.mx {
68 let host = hosts.entry(rec.target.clone()).or_default();
69 host.name = rec.target.clone();
70 host.origins.insert(Origins::DNS);
71 }
72
73 for host in hosts.values_mut() {
75 host.ct_refs = ct::cert_refs(&certs, &host.name);
76 }
77
78 let resolution: Vec<(String, Option<IpAddr>, DnsStatus)> =
80 futures::stream::iter(hosts.keys().map(|name| {
81 let resolver = resolver.clone();
82 let name = name.clone();
83 async move {
84 let (ip, status) = resolve::resolve_status(&resolver, &name).await;
85 (name, ip, status)
86 }
87 }))
88 .buffer_unordered(cfg.dns_concurrency)
89 .collect()
90 .await;
91
92 for (name, ip, status) in &resolution {
93 let host = hosts.get_mut(name).expect("host from map");
94 host.resolves = Some(ip.is_some());
95 host.dns_status = Some(*status);
96 }
97
98 if opts.probe {
100 let snapshot = if opts.refresh {
101 None
102 } else {
103 probe_cache::load(domain)
104 };
105 let now = Utc::now();
106 let carried_ports: BTreeMap<String, BTreeMap<u16, PortScan>> = snapshot
107 .as_ref()
108 .map(|s| s.ports.clone())
109 .unwrap_or_default();
110 let cached: BTreeMap<String, Endpoint> = snapshot
111 .map(|s| {
112 s.probes
113 .into_iter()
114 .filter(|(_, ep)| probe_cache::is_fresh(ep, cfg.probe_cache_ttl, now))
115 .collect()
116 })
117 .unwrap_or_default();
118
119 let endpoints: Vec<(String, Endpoint, bool)> =
120 futures::stream::iter(resolution.iter().map(|(name, ip, _)| {
121 let name = name.clone();
122 let hit = if ip.is_some() {
123 cached.get(&name).filter(|ep| ep.ip.is_some()).cloned()
124 } else {
125 None
126 };
127 async move {
128 match hit {
129 Some(endpoint) => (name, endpoint, true),
130 None => {
131 let endpoint = if ip.is_some() {
132 crate::probe::probe_one(&name, *ip, TLS_PORT, cfg).await
133 } else {
134 Endpoint::failed(ProbeStatus::DnsFailure)
135 };
136 (name, endpoint, false)
137 }
138 }
139 }
140 }))
141 .buffer_unordered(cfg.probe_concurrency)
142 .collect()
143 .await;
144
145 let (mut cached_n, mut fresh_n, mut oldest_cached_secs) = (0usize, 0usize, 0u64);
146 for (name, endpoint, hit) in endpoints {
147 if hit {
148 cached_n += 1;
149 if let Some(t) = endpoint.observed_at {
150 oldest_cached_secs =
151 oldest_cached_secs.max((now - t).num_seconds().max(0) as u64);
152 }
153 } else {
154 fresh_n += 1;
155 }
156 if let Some(host) = hosts.get_mut(&name) {
157 host.endpoint = Some(endpoint);
158 }
159 }
160
161 let probes: BTreeMap<String, Endpoint> = hosts
162 .values()
163 .filter_map(|h| h.endpoint.clone().map(|ep| (h.name.clone(), ep)))
164 .collect();
165 probe_cache::store(domain, &probes, &carried_ports);
166
167 if cached_n > 0 {
168 eprintln!(
169 "probes: {cached_n} cached ({} min old), {fresh_n} probed fresh",
170 oldest_cached_secs / 60
171 );
172 } else {
173 eprintln!("probes: {fresh_n} probed fresh");
174 }
175 }
176
177 if opts.probe
178 && let Some(explicit_ports) = &opts.ports
179 {
180 let mx_names: Vec<String> = discovery.mx.iter().map(|rec| rec.target.clone()).collect();
181 let snapshot = if opts.refresh {
182 None
183 } else {
184 probe_cache::load(domain)
185 };
186 let now = Utc::now();
187 let carried: BTreeMap<String, BTreeMap<u16, PortScan>> = snapshot
188 .as_ref()
189 .map(|s| s.ports.clone())
190 .unwrap_or_default();
191 let cached_ports: BTreeMap<String, BTreeMap<u16, PortScan>> = snapshot
192 .map(|s| {
193 s.ports
194 .into_iter()
195 .map(|(host, scans)| {
196 (
197 host,
198 scans
199 .into_iter()
200 .filter(|(_, scan)| {
201 probe_cache::scan_fresh(scan, cfg.sweep_ttl, now)
202 })
203 .collect::<BTreeMap<u16, PortScan>>(),
204 )
205 })
206 .filter(|(_, scans)| !scans.is_empty())
207 .collect()
208 })
209 .unwrap_or_default();
210
211 let mut targets: Vec<(String, IpAddr)> = resolution
212 .iter()
213 .filter(|(name, ip, _)| {
214 ip.is_some()
215 && hosts
216 .get(name)
217 .and_then(|h| h.endpoint.as_ref())
218 .is_some_and(|e| {
219 matches!(
220 e.status,
221 ProbeStatus::TcpRefused
222 | ProbeStatus::TcpUnreachable
223 | ProbeStatus::Timeout { .. }
224 )
225 })
226 })
227 .filter_map(|(name, ip, _)| ip.map(|ip| (name.clone(), ip)))
228 .collect();
229
230 let mut seed = std::time::SystemTime::now()
231 .duration_since(std::time::UNIX_EPOCH)
232 .map(|t| t.subsec_nanos() as u64)
233 .unwrap_or(1);
234 for i in (1..targets.len()).rev() {
235 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
236 let j = (seed >> 33) as usize % (i + 1);
237 targets.swap(i, j);
238 }
239
240 let mx_targets = std::sync::Arc::new(
241 mx_names
242 .iter()
243 .cloned()
244 .collect::<std::collections::BTreeSet<String>>(),
245 );
246 let cached_ports_locked = std::sync::Arc::new(cached_ports);
247 let sweep_results: Vec<(String, Vec<PortScan>, usize)> =
248 futures::stream::iter(targets.into_iter().map(|(name, ip)| {
249 let cached_ports_locked = std::sync::Arc::clone(&cached_ports_locked);
250 let mx_targets = std::sync::Arc::clone(&mx_targets);
251 async move {
252 let is_mx = mx_targets.contains(&name);
253 let ports = match explicit_ports {
254 list if list.is_empty() => {
255 crate::probe::sweep::ports_for_host(&name, &cfg.probe_ports, is_mx)
256 }
257 list => list
258 .iter()
259 .take(crate::probe::sweep::PORT_CAP)
260 .map(|p| (*p, crate::probe::sweep::PortKind::DirectTls))
261 .collect(),
262 };
263 let mut scans: Vec<PortScan> = Vec::new();
264 for (port, kind) in ports {
265 let cached_hit = cached_ports_locked
266 .get(&name)
267 .and_then(|m| m.get(&port))
268 .filter(|scan| probe_cache::scan_fresh(scan, cfg.sweep_ttl, now))
269 .cloned();
270 if let Some(scan) = cached_hit {
271 scans.push(scan);
272 } else {
273 scans.extend(
274 crate::probe::sweep::sweep_host(&name, ip, &[(port, kind)], cfg)
275 .await,
276 );
277 }
278 }
279 let fresh = scans
280 .iter()
281 .filter(|s| {
282 cached_ports_locked
283 .get(&name)
284 .and_then(|m| m.get(&s.port))
285 .map(|c| c.observed_at != s.observed_at)
286 .unwrap_or(true)
287 })
288 .count();
289 (name, scans, fresh)
290 }
291 }))
292 .buffer_unordered(cfg.sweep_concurrency)
293 .collect()
294 .await;
295
296 let (mut fresh_sweeps, mut cached_sweeps, mut open_hosts) = (0usize, 0usize, 0usize);
297 let mut ports_map: BTreeMap<String, BTreeMap<u16, PortScan>> = carried;
298 for (name, scans, fresh) in sweep_results {
299 fresh_sweeps += fresh;
300 cached_sweeps += scans.len() - fresh;
301 if scans.iter().any(|s| s.state == ScanState::Open) {
302 open_hosts += 1;
303 }
304 if let Some(host) = hosts.get_mut(&name) {
305 host.ports = scans.clone();
306 }
307 let entry = ports_map.entry(name).or_default();
308 for scan in scans {
309 entry.insert(scan.port, scan);
310 }
311 }
312
313 let probes: BTreeMap<String, Endpoint> = hosts
314 .values()
315 .filter_map(|h| h.endpoint.clone().map(|ep| (h.name.clone(), ep)))
316 .collect();
317 probe_cache::store(domain, &probes, &ports_map);
318 eprintln!(
319 "ports: {cached_sweeps} cached, {fresh_sweeps} swept fresh, {open_hosts} with open ports"
320 );
321 }
322
323 let mut certs = certs;
325 for host in hosts.values() {
326 let served_certs = host
327 .endpoint
328 .as_ref()
329 .and_then(|e| e.served.as_ref())
330 .into_iter()
331 .chain(host.ports.iter().filter_map(|s| s.served.as_ref()));
332 for served in served_certs {
333 if let Some(cert) = certs.get_mut(&served.serial) {
334 cert.served_live = true;
335 if cert.cert_signature.is_none() {
336 cert.cert_signature = served.cert_signature.clone();
337 }
338 if cert.pubkey_alg.is_none() {
339 cert.pubkey_alg = served.pubkey_alg.clone();
340 }
341 if cert.sig_alg.is_none() {
342 cert.sig_alg = served.sig_alg.clone();
343 }
344 }
345 }
346 }
347 for host in hosts.values_mut() {
348 if let Some(endpoint) = &mut host.endpoint {
349 if let Some(served) = &endpoint.served {
350 endpoint.served_not_in_ct = !certs.contains_key(&served.serial);
351 }
352 }
353 }
354
355 Ok(DomainState {
356 domain: domain.to_string(),
357 checked_at: Local::now(),
358 provenance: outcome.provenance,
359 ct_stats,
360 certs,
361 hosts,
362 srv: discovery.srv,
363 https: discovery.https,
364 mx: discovery.mx,
365 })
366}
367
368fn ct_stats(certs: &BTreeMap<String, crate::model::Certificate>) -> CtStats {
369 let now = Utc::now();
370 let mut stats = CtStats {
371 rows: certs.len(),
372 valid: 0,
373 future: 0,
374 };
375 for cert in certs.values() {
376 if cert.currently_valid {
377 stats.valid += 1;
378 } else if ct::future_valid(cert.not_before, now) {
379 stats.future += 1;
380 }
381 }
382 stats
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::model::Certificate;
389
390 fn cert(serial: &str, nb: &str, na: &str) -> Certificate {
391 let parse = |s: &str| {
392 chrono::DateTime::parse_from_rfc3339(s)
393 .unwrap()
394 .with_timezone(&Utc)
395 };
396 let (nb, na) = (Some(parse(nb)), Some(parse(na)));
397 Certificate {
398 serial: serial.to_string(),
399 in_ct: true,
400 served_live: false,
401 currently_valid: ct::currently_valid(nb, na, Utc::now()),
402 ct_ids: vec![1],
403 common_name: None,
404 identities: vec![],
405 not_before: nb,
406 not_after: na,
407 pubkey_alg: None,
408 sig_alg: None,
409 issuer: None,
410 key_algorithm: None,
411 key_size: None,
412 sig_key_algorithm: None,
413 sig_hash_algorithm: None,
414 cert_signature: None,
415 chain: Vec::new(),
416 }
417 }
418
419 #[test]
420 fn ct_stats_counts_grouped_certs() {
421 let mut certs = BTreeMap::new();
422 certs.insert(
423 "aa11".to_string(),
424 cert("aa11", "2026-01-01T00:00:00Z", "2027-01-01T00:00:00Z"),
425 );
426 certs.insert(
427 "bb22".to_string(),
428 cert("bb22", "2027-06-01T00:00:00Z", "2028-01-01T00:00:00Z"),
429 );
430 certs.insert(
431 "cc33".to_string(),
432 cert("cc33", "2020-01-01T00:00:00Z", "2021-01-01T00:00:00Z"),
433 );
434 let stats = ct_stats(&certs);
435 assert_eq!(stats.rows, 3);
436 assert_eq!(stats.valid, 1);
437 assert_eq!(stats.future, 1);
438 }
439}