Skip to main content

pq_map/
collect.rs

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    // 1. CT snapshot.
35    let outcome = fetch::obtain(domain, cfg, opts.refresh).await?;
36    let certs = outcome.certs;
37    let ct_stats = ct_stats(&certs);
38
39    // 2. DNS: SRV sweep + SVCB/HTTPS on the apex.
40    let resolver = dns::build_resolver(cfg).map_err(|e| Error::Resolver(e.to_string()))?;
41    let discovery = dns::discover(domain, &resolver).await;
42
43    // 3. Host set: concrete CT names ∪ apex ∪ in-domain SRV targets.
44    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    // 4. CT coverage per host (exact / wildcard refs).
74    for host in hosts.values_mut() {
75        host.ct_refs = ct::cert_refs(&certs, &host.name);
76    }
77
78    // 5. Resolve every host concurrently, capturing the response status.
79    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    // 6. Probe resolving hosts.
99    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    // 7. Correlate served certificates with CT; propagate signature facts.
324    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}