1use std::collections::BTreeMap;
2use std::io::Read;
3use std::thread;
4use std::time::Duration;
5
6use chrono::Utc;
7
8use crate::config::Config;
9use crate::model::{Certificate, Provenance};
10
11use super::cache;
12use super::chain_cache;
13use super::ct;
14use super::ctdb;
15
16#[derive(Debug, thiserror::Error)]
17pub enum Error {
18 #[error("unable to retrieve CT data and no usable cache exists")]
19 NoCtData,
20 #[error("invalid CT data: {0}")]
21 CtParse(String),
22 #[error("task join failed")]
23 Join,
24}
25
26pub struct FetchOutcome {
27 pub certs: BTreeMap<String, Certificate>,
28 pub provenance: Provenance,
29 pub source: Option<&'static str>,
31}
32
33pub async fn obtain(domain: &str, cfg: &Config, refresh: bool) -> Result<FetchOutcome, Error> {
34 let file = cache::cache_file(domain);
35
36 if !refresh {
37 if let Some(snap) = cache::load_snapshot(&file) {
38 if let Some(age) = cache::age(&file).filter(|a| *a < cfg.cache_ttl) {
39 eprintln!(
40 "CT: using cached snapshot for {domain} ({} min old, source {})",
41 age / 60,
42 snap.source
43 );
44 return Ok(FetchOutcome {
45 certs: ct::revive_certs(snap.certs, Utc::now()),
46 provenance: Provenance::Cached { age_secs: age },
47 source: None,
48 });
49 }
50 }
51 }
52
53 match ctdb::fetch_rows(domain, cfg).await {
54 Ok(rows) if !rows.is_empty() => {
55 let certs = ct::certificates_from_db(&rows, Utc::now());
56 if !certs.is_empty() {
57 let certs = attach_chains(domain, refresh, certs, cfg).await;
58 store(&file, "db", &certs);
59 return Ok(FetchOutcome {
60 certs,
61 provenance: Provenance::Fresh,
62 source: Some("db"),
63 });
64 }
65 eprintln!("CT: certwatch DB returned no usable certificates");
66 }
67 Ok(_) => eprintln!("CT: certwatch DB returned an empty result; not caching"),
68 Err(e) => eprintln!("CT: falling back to crt.sh HTTP: {e}"),
69 }
70
71 let owned = domain.to_string();
72 let cfg_http = cfg.clone();
73 let http = tokio::task::spawn_blocking(move || http_obtain(&owned, &cfg_http))
74 .await
75 .map_err(|_| Error::Join)?;
76 match http {
77 Ok(body) => {
78 let rows = ct::parse_rows(&body).map_err(|e| Error::CtParse(e.to_string()))?;
79 let certs = ct::certificates(&rows, Utc::now());
80 if !certs.is_empty() {
81 store(&file, "http", &certs);
82 return Ok(FetchOutcome {
83 certs,
84 provenance: Provenance::Fresh,
85 source: Some("http"),
86 });
87 }
88 eprintln!("CT: crt.sh returned no usable certificates");
89 }
90 Err(e) => eprintln!("CT: {e}"),
91 }
92
93 if let Some(snap) = cache::load_snapshot(&file) {
94 eprintln!("WARNING: CT sources unavailable; using stale cached snapshot");
95 return Ok(FetchOutcome {
96 certs: ct::revive_certs(snap.certs, Utc::now()),
97 provenance: Provenance::Stale {
98 age_secs: cache::age(&file),
99 },
100 source: None,
101 });
102 }
103 Err(Error::NoCtData)
104}
105
106async fn attach_chains(
107 domain: &str,
108 refresh: bool,
109 mut certs: BTreeMap<String, Certificate>,
110 cfg: &Config,
111) -> BTreeMap<String, Certificate> {
112 let leaf_ids: Vec<u64> = {
113 let mut ids: Vec<u64> = certs
114 .values()
115 .filter_map(|c| c.ct_ids.first().copied())
116 .collect();
117 ids.sort_unstable();
118 ids.dedup();
119 ids
120 };
121 if leaf_ids.is_empty() {
122 return certs;
123 }
124
125 if !refresh {
126 if let Some(snap) = chain_cache::load(domain) {
127 if let Some(age) = chain_cache::age(domain).filter(|a| *a < cfg.cache_ttl) {
128 eprintln!("CT: using cached chains ({domain}, {} min old)", age / 60);
129 for cert in certs.values_mut() {
130 if let Some(id) = cert.ct_ids.first() {
131 cert.chain = snap.chains.get(id).cloned().unwrap_or_default();
132 }
133 }
134 return certs;
135 }
136 }
137 }
138
139 match ctdb::chain_rows(&leaf_ids, cfg).await {
140 Ok(chains) => {
141 if !chains.is_empty() {
142 chain_cache::store(domain, &chains);
143 }
144 for cert in certs.values_mut() {
145 if let Some(id) = cert.ct_ids.first() {
146 cert.chain = chains.get(id).cloned().unwrap_or_default();
147 }
148 }
149 }
150 Err(e) => {
151 eprintln!("CT: chain query failed, proceeding without chains: {e}");
152 if let Some(snap) = chain_cache::load(domain) {
153 eprintln!("CT: using stale chains ({domain})");
154 for cert in certs.values_mut() {
155 if let Some(id) = cert.ct_ids.first() {
156 cert.chain = snap.chains.get(id).cloned().unwrap_or_default();
157 }
158 }
159 }
160 }
161 }
162 certs
163}
164
165enum FetchFailure {
167 Status(u16, Option<u64>),
169 Other(String),
171}
172
173pub fn backoff_secs(attempt: u32, retry_delay: u64) -> u64 {
175 retry_delay
176 .saturating_mul(1u64 << (attempt - 1).min(6))
177 .min(60)
178}
179
180fn http_obtain(domain: &str, cfg: &Config) -> Result<String, Error> {
181 for attempt in 1..=cfg.retries {
182 eprintln!(
183 "CT: querying crt.sh for *.{domain} (attempt {attempt}/{})",
184 cfg.retries
185 );
186 match fetch_once(domain, cfg) {
187 Ok(body) => match ct::parse_rows(&body) {
188 Ok(rows) if !rows.is_empty() => return Ok(body),
189 Ok(_) => eprintln!("CT: crt.sh returned an empty result; not caching"),
190 Err(_) => eprintln!("CT: crt.sh returned invalid JSON"),
191 },
192 Err(FetchFailure::Status(code, retry_after)) => {
193 let base = backoff_secs(attempt, cfg.retry_delay);
194 let (why, delay) = match code {
195 429 => ("rate limited", retry_after.unwrap_or(base * 2).min(300)),
198 502 | 503 => ("server error", base),
199 _ => ("HTTP error", base),
200 };
201 if attempt < cfg.retries {
202 eprintln!(
203 "CT: {why} (HTTP {code}); retrying in {delay}s (attempt {attempt}/{} failed)",
204 cfg.retries
205 );
206 thread::sleep(Duration::from_secs(delay));
207 } else {
208 eprintln!(
209 "CT: {why} (HTTP {code}); giving up after {} attempts",
210 cfg.retries
211 );
212 }
213 }
214 Err(FetchFailure::Other(e)) => {
215 eprintln!("CT: {e}");
216 if attempt < cfg.retries {
217 let delay = backoff_secs(attempt, cfg.retry_delay);
218 eprintln!(
219 "CT: retrying in {delay}s (attempt {}/{} failed)",
220 attempt, cfg.retries
221 );
222 thread::sleep(Duration::from_secs(delay));
223 }
224 }
225 }
226 }
227 Err(Error::NoCtData)
228}
229
230fn fetch_once(domain: &str, cfg: &Config) -> Result<String, FetchFailure> {
231 let agent = ureq::AgentBuilder::new()
232 .timeout_connect(Duration::from_secs(cfg.connect_timeout))
233 .timeout(Duration::from_secs(cfg.max_time))
234 .build();
235 let url = format!("https://crt.sh/?q=%25.{domain}&exclude=expired&output=json");
236 let response = match agent.get(&url).call() {
237 Ok(r) => r,
238 Err(ureq::Error::Status(code, resp)) => {
239 let retry_after = resp
240 .header("retry-after")
241 .and_then(|v| v.trim().parse::<u64>().ok());
242 return Err(FetchFailure::Status(code, retry_after));
243 }
244 Err(ureq::Error::Transport(t)) => {
245 return Err(FetchFailure::Other(format!("crt.sh request failed: {t}")));
246 }
247 };
248 let mut reader = response.into_reader().take(256 * 1024 * 1024);
249 let mut body = String::new();
250 reader
251 .read_to_string(&mut body)
252 .map_err(|e| FetchFailure::Other(format!("crt.sh read failed: {e}")))?;
253 Ok(body)
254}
255
256fn store(file: &std::path::Path, source: &str, certs: &BTreeMap<String, Certificate>) {
257 let snapshot = cache::Snapshot {
258 version: cache::SNAPSHOT_VERSION,
259 source: source.to_string(),
260 fetched_at: Utc::now().to_rfc3339(),
261 certs: certs.values().cloned().collect(),
262 };
263 match cache::store_snapshot(file, &snapshot) {
264 Ok(()) => eprintln!("CT: snapshot cached (source {source}): {}", file.display()),
265 Err(e) => eprintln!("CT: caching failed: {e}"),
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn backoff_is_exponential_and_capped() {
275 assert_eq!(backoff_secs(1, 2), 2);
276 assert_eq!(backoff_secs(2, 2), 4);
277 assert_eq!(backoff_secs(3, 2), 8);
278 assert_eq!(backoff_secs(4, 2), 16);
279 assert_eq!(backoff_secs(6, 2), 60, "capped at 60s");
280 assert_eq!(backoff_secs(20, 2), 60);
281 assert_eq!(backoff_secs(1, 120), 60, "capped at 60s");
282 }
283}