1use std::time::Duration;
11
12const REQUEST_TIMEOUT_MS: u64 = 15_000;
13const RECORD_TTL: u32 = 60;
15
16pub const CLOUDFLARE_API: &str = "https://api.cloudflare.com/client/v4";
18pub const ROUTE53_API: &str = "https://route53.amazonaws.com";
20
21#[derive(Clone)]
23pub struct Secret(pub String);
24
25impl std::fmt::Debug for Secret {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.write_str("(hidden)")
28 }
29}
30
31#[derive(Clone)]
33pub struct AwsCredentials {
34 pub access_key_id: String,
35 pub secret_access_key: String,
36 pub session_token: Option<String>,
39}
40
41impl std::fmt::Debug for AwsCredentials {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("AwsCredentials").field("access_key_id", &self.access_key_id).field("secret_access_key", &"(hidden)").finish()
44 }
45}
46
47impl AwsCredentials {
48 pub fn from_env() -> Option<AwsCredentials> {
51 let get = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
52 Some(AwsCredentials {
53 access_key_id: get("AWS_ACCESS_KEY_ID")?,
54 secret_access_key: get("AWS_SECRET_ACCESS_KEY")?,
55 session_token: get("AWS_SESSION_TOKEN"),
56 })
57 }
58}
59
60async fn send(method: &str, url: &str, headers: &[(String, String)], body: Option<Vec<u8>>) -> Result<(u16, String), String> {
61 let client = crate::http_client::AsyncClient::new().max_redirects(0);
62 let mut request = client.request(method, url).timeout_ms(REQUEST_TIMEOUT_MS);
63 for (name, value) in headers {
64 request = request.header(name, value);
65 }
66 if let Some(body) = body {
67 request = request.body(body);
68 }
69 let response = request.send().await.map_err(|e| format!("{method} {url}: {e}"))?;
70 Ok((response.status(), String::from_utf8_lossy(response.bytes()).into_owned()))
71}
72
73#[doc(hidden)]
76pub fn zone_candidates(name: &str) -> Vec<String> {
77 let labels: Vec<&str> = name.trim_end_matches('.').split('.').collect();
78 (1..labels.len().saturating_sub(1)).map(|i| labels[i..].join(".")).collect()
79}
80
81fn cloudflare_headers(token: &str) -> Vec<(String, String)> {
84 vec![("Authorization".to_string(), format!("Bearer {token}")), ("Content-Type".to_string(), "application/json".to_string())]
85}
86
87fn cloudflare_ok(context: &str, (status, body): (u16, String)) -> Result<String, String> {
89 let success = body.find("\"success\"").is_some_and(|at| body[at + 9..].trim_start_matches([' ', ':']).starts_with("true"));
90 if (200..300).contains(&status) && success {
91 return Ok(body);
92 }
93 let message = super::json_str(&body, "message").unwrap_or_else(|| body.chars().take(200).collect());
94 Err(format!("Cloudflare {context}: HTTP {status}: {message}"))
95}
96
97async fn cloudflare_zone(token: &str, api: &str, zone_id: Option<&str>, name: &str) -> Result<String, String> {
98 if let Some(zone_id) = zone_id {
99 return Ok(zone_id.to_string());
100 }
101 for candidate in zone_candidates(name) {
102 let body = cloudflare_ok("zone lookup", send("GET", &format!("{api}/zones?name={candidate}"), &cloudflare_headers(token), None).await?)?;
103 if let Some(id) = super::json_objects(&body, "result").first().and_then(|zone| super::json_str(zone, "id")) {
104 return Ok(id);
105 }
106 }
107 Err(format!("Cloudflare has no zone for {name} this token can see (set dns_zone_id, or give the token Zone:Read)"))
108}
109
110async fn cloudflare_records(token: &str, api: &str, zone: &str, name: &str, value: Option<&str>) -> Result<Vec<String>, String> {
112 let url = format!("{api}/zones/{zone}/dns_records?type=TXT&name={name}");
113 let body = cloudflare_ok("record lookup", send("GET", &url, &cloudflare_headers(token), None).await?)?;
114 Ok(super::json_objects(&body, "result")
115 .iter()
116 .filter(|record| value.is_none_or(|value| super::json_str(record, "content").is_some_and(|c| c.trim_matches('"') == value)))
117 .filter_map(|record| super::json_str(record, "id"))
118 .collect())
119}
120
121async fn cloudflare_delete(token: &str, api: &str, zone: &str, id: &str) -> Result<(), String> {
122 cloudflare_ok("record delete", send("DELETE", &format!("{api}/zones/{zone}/dns_records/{id}"), &cloudflare_headers(token), None).await?).map(|_| ())
123}
124
125#[doc(hidden)]
127pub async fn cloudflare_present(token: &str, api: &str, zone_id: Option<&str>, name: &str, value: &str) -> Result<(), String> {
128 let zone = cloudflare_zone(token, api, zone_id, name).await?;
129 for id in cloudflare_records(token, api, &zone, name, None).await? {
130 cloudflare_delete(token, api, &zone, &id).await?;
131 }
132 let record = format!(
133 r#"{{"type":"TXT","name":"{}","content":"\"{}\"","ttl":{RECORD_TTL}}}"#,
134 super::json_escape(name),
135 super::json_escape(value)
136 );
137 let url = format!("{api}/zones/{zone}/dns_records");
138 cloudflare_ok("record create", send("POST", &url, &cloudflare_headers(token), Some(record.into_bytes())).await?).map(|_| ())
139}
140
141#[doc(hidden)]
143pub async fn cloudflare_cleanup(token: &str, api: &str, zone_id: Option<&str>, name: &str, value: &str) -> Result<(), String> {
144 let zone = cloudflare_zone(token, api, zone_id, name).await?;
145 for id in cloudflare_records(token, api, &zone, name, Some(value)).await? {
146 cloudflare_delete(token, api, &zone, &id).await?;
147 }
148 Ok(())
149}
150
151#[doc(hidden)]
155pub fn xml_text<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
156 let open = format!("<{tag}>");
157 let start = xml.find(&open)? + open.len();
158 let end = xml[start..].find(&format!("</{tag}>"))?;
159 Some(&xml[start..start + end])
160}
161
162fn url_host(url: &str) -> &str {
168 let (scheme, rest) = url.split_once("://").unwrap_or(("https", url));
169 let authority = rest.split('/').next().unwrap_or("");
170 let default = if scheme.eq_ignore_ascii_case("http") { ":80" } else { ":443" };
171 authority.strip_suffix(default).unwrap_or(authority)
172}
173
174async fn route53_send(credentials: &AwsCredentials, api: &str, method: &str, path: &str, query: &str, body: Option<String>) -> Result<String, String> {
175 let payload = body.as_deref().unwrap_or("").as_bytes();
176 let amz_date = amz_date(super::unix_now().max(0) as u64);
177 let headers = sigv4_headers(credentials, method, url_host(api), path, query, payload, &amz_date, "us-east-1", "route53");
178 let url = if query.is_empty() { format!("{api}{path}") } else { format!("{api}{path}?{query}") };
179 let (status, reply) = send(method, &url, &headers, body.map(String::into_bytes)).await?;
180 if (200..300).contains(&status) {
181 return Ok(reply);
182 }
183 let message = xml_text(&reply, "Message").map(str::to_string).unwrap_or_else(|| reply.chars().take(200).collect());
184 Err(format!("Route 53 {method} {path}: HTTP {status}: {message}"))
185}
186
187async fn route53_zone(credentials: &AwsCredentials, api: &str, zone_id: Option<&str>, name: &str) -> Result<String, String> {
188 if let Some(zone_id) = zone_id {
189 return Ok(zone_id.trim_start_matches("/hostedzone/").to_string());
190 }
191 for candidate in zone_candidates(name) {
192 let query = format!("dnsname={candidate}&maxitems=1");
193 let reply = route53_send(credentials, api, "GET", "/2013-04-01/hostedzonesbyname", &query, None).await?;
194 let found = xml_text(&reply, "Name").is_some_and(|n| n.trim_end_matches('.').eq_ignore_ascii_case(&candidate));
195 if let (true, Some(id)) = (found, xml_text(&reply, "Id")) {
196 return Ok(id.trim_start_matches("/hostedzone/").to_string());
197 }
198 }
199 Err(format!("Route 53 has no hosted zone for {name} (set dns_zone_id)"))
200}
201
202#[doc(hidden)]
204pub fn route53_change_body(action: &str, name: &str, value: &str) -> String {
205 format!(
206 concat!(
207 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
208 r#"<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><ChangeBatch><Changes><Change>"#,
209 "<Action>{}</Action><ResourceRecordSet><Name>{}.</Name><Type>TXT</Type><TTL>{}</TTL>",
210 r#"<ResourceRecords><ResourceRecord><Value>"{}"</Value></ResourceRecord></ResourceRecords>"#,
211 "</ResourceRecordSet></Change></Changes></ChangeBatch></ChangeResourceRecordSetsRequest>"
212 ),
213 action,
214 name.trim_end_matches('.'),
215 RECORD_TTL,
216 value
217 )
218}
219
220#[doc(hidden)]
224pub async fn route53_change(credentials: &AwsCredentials, api: &str, zone_id: Option<&str>, action: &str, name: &str, value: &str, wait: Duration) -> Result<(), String> {
225 let zone = route53_zone(credentials, api, zone_id, name).await?;
226 let path = format!("/2013-04-01/hostedzone/{zone}/rrset");
227 let reply = route53_send(credentials, api, "POST", &path, "", Some(route53_change_body(action, name, value))).await?;
228 if action != "UPSERT" {
229 return Ok(());
230 }
231 let Some(change) = xml_text(&reply, "Id").map(|id| id.trim_start_matches("/change/").to_string()) else { return Ok(()) };
232 let deadline = tokio::time::Instant::now() + wait;
233 let mut status = xml_text(&reply, "Status").unwrap_or("").to_string();
234 while status != "INSYNC" && tokio::time::Instant::now() < deadline {
235 tokio::time::sleep(Duration::from_secs(3)).await;
236 let reply = route53_send(credentials, api, "GET", &format!("/2013-04-01/change/{change}"), "", None).await?;
237 status = xml_text(&reply, "Status").unwrap_or("").to_string();
238 }
239 if status != "INSYNC" {
240 eprintln!("[ACME] Route 53 change {change} is still {status}; going on.");
241 }
242 Ok(())
243}
244
245#[doc(hidden)]
247pub fn amz_date(unix: u64) -> String {
248 let (days, secs) = (unix / 86_400, unix % 86_400);
249 let z = days as i64 + 719_468;
251 let era = z.div_euclid(146_097);
252 let doe = z - era * 146_097;
253 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
254 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
255 let mp = (5 * doy + 2) / 153;
256 let day = doy - (153 * mp + 2) / 5 + 1;
257 let month = if mp < 10 { mp + 3 } else { mp - 9 };
258 let year = yoe + era * 400 + i64::from(month <= 2);
259 format!("{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60)
260}
261
262fn hex(bytes: &[u8]) -> String {
263 bytes.iter().map(|b| format!("{b:02x}")).collect()
264}
265
266fn hmac(key: &[u8], data: &str) -> Vec<u8> {
267 let key = aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, key);
268 aws_lc_rs::hmac::sign(&key, data.as_bytes()).as_ref().to_vec()
269}
270
271fn sha256_hex(data: &[u8]) -> String {
272 hex(aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, data).as_ref())
273}
274
275#[allow(clippy::too_many_arguments)]
280#[doc(hidden)]
281pub fn sigv4_headers(
282 credentials: &AwsCredentials,
283 method: &str,
284 host: &str,
285 path: &str,
286 query: &str,
287 payload: &[u8],
288 amz_date: &str,
289 region: &str,
290 service: &str,
291) -> Vec<(String, String)> {
292 let mut signed = vec![("host", host.to_string()), ("x-amz-date", amz_date.to_string())];
293 if let Some(token) = &credentials.session_token {
294 signed.push(("x-amz-security-token", token.clone()));
295 }
296 let canonical_headers: String = signed.iter().map(|(name, value)| format!("{name}:{}\n", value.trim())).collect();
297 let signed_headers = signed.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(";");
298 let canonical_request = format!("{method}\n{path}\n{query}\n{canonical_headers}\n{signed_headers}\n{}", sha256_hex(payload));
299 let date = &amz_date[..8];
300 let scope = format!("{date}/{region}/{service}/aws4_request");
301 let string_to_sign = format!("AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}", sha256_hex(canonical_request.as_bytes()));
302 let mut key = hmac(format!("AWS4{}", credentials.secret_access_key).as_bytes(), date);
303 for part in [region, service, "aws4_request"] {
304 key = hmac(&key, part);
305 }
306 let signature = hex(&hmac(&key, &string_to_sign));
307 let mut headers = vec![("X-Amz-Date".to_string(), amz_date.to_string())];
308 if let Some(token) = &credentials.session_token {
309 headers.push(("X-Amz-Security-Token".to_string(), token.clone()));
310 }
311 headers.push((
312 "Authorization".to_string(),
313 format!("AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}", credentials.access_key_id),
314 ));
315 headers
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 fn example_credentials() -> AwsCredentials {
323 AwsCredentials {
324 access_key_id: "AKIDEXAMPLE".to_string(),
325 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
326 session_token: None,
327 }
328 }
329
330 #[test]
332 fn sigv4_matches_the_aws_test_suite() {
333 let headers = sigv4_headers(&example_credentials(), "GET", "example.amazonaws.com", "/", "", b"", "20150830T123600Z", "us-east-1", "service");
334 assert_eq!(
335 Some(&(
336 "Authorization".to_string(),
337 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31".to_string()
338 )),
339 headers.last()
340 );
341 assert_eq!(("X-Amz-Date".to_string(), "20150830T123600Z".to_string()), headers[0]);
342 }
343
344 #[test]
345 fn a_session_token_is_sent_and_signed() {
346 let credentials = AwsCredentials { session_token: Some("TOKEN".to_string()), ..example_credentials() };
347 let headers = sigv4_headers(&credentials, "GET", "example.amazonaws.com", "/", "", b"", "20150830T123600Z", "us-east-1", "service");
348 assert!(headers.contains(&("X-Amz-Security-Token".to_string(), "TOKEN".to_string())));
349 assert!(headers.last().unwrap().1.contains("SignedHeaders=host;x-amz-date;x-amz-security-token"));
350 }
351
352 #[test]
353 fn amz_dates() {
354 assert_eq!("19700101T000000Z", amz_date(0));
355 assert_eq!("20150830T123600Z", amz_date(1_440_938_160));
356 assert_eq!("20240229T235959Z", amz_date(1_709_251_199));
357 }
358
359 #[test]
360 fn zone_candidates_go_from_longest_to_two_labels() {
361 assert_eq!(vec!["a.example.com", "example.com"], zone_candidates("_acme-challenge.a.example.com"));
362 assert_eq!(vec!["example.com"], zone_candidates("_acme-challenge.example.com."));
363 assert!(zone_candidates("example.com").is_empty());
364 }
365
366 #[test]
367 fn route53_change_xml() {
368 let body = route53_change_body("UPSERT", "_acme-challenge.example.com", "abc");
369 assert_eq!(Some("UPSERT"), xml_text(&body, "Action"));
370 assert_eq!(Some("_acme-challenge.example.com."), xml_text(&body, "Name"));
371 assert_eq!(Some("\"abc\""), xml_text(&body, "Value"));
372 assert_eq!(Some("60"), xml_text(&body, "TTL"));
373 }
374
375 #[test]
376 fn hosts_are_signed_as_sent() {
377 assert_eq!("route53.amazonaws.com", url_host(ROUTE53_API));
378 assert_eq!("127.0.0.1:9181", url_host("http://127.0.0.1:9181/x"));
380 assert_eq!("[::1]:9181", url_host("http://[::1]:9181"));
381 assert_eq!("example.test", url_host("http://example.test:80/x"));
382 assert_eq!("example.test", url_host("https://example.test:443"));
383 }
384
385 #[test]
386 fn cloudflare_replies() {
387 assert!(cloudflare_ok("x", (200, r#"{"result":[],"success": true}"#.to_string())).is_ok());
388 let error = cloudflare_ok("x", (403, r#"{"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}"#.to_string())).unwrap_err();
389 assert!(error.contains("HTTP 403") && error.contains("Authentication error"), "{error}");
390 assert!(cloudflare_ok("x", (200, r#"{"success":false}"#.to_string())).is_err());
391 }
392
393 #[test]
394 fn credentials_are_not_printed() {
395 assert!(!format!("{:?}", example_credentials()).contains("EXAMPLEKEY"));
396 }
397}