Skip to main content

rws_acme/acme/
dns_api.rs

1//! DNS-01 records through DNS provider APIs: Cloudflare (an API token with
2//! DNS edit permission; Zone read too unless `dns_zone_id` is set) and
3//! Amazon Route 53 (requests signed with AWS Signature Version 4, credentials
4//! from the standard `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
5//! `AWS_SESSION_TOKEN`). The zone is configured (`dns_zone_id`) or found by
6//! asking the API for each parent of the record name in turn. The API base
7//! URL can be replaced (`dns_api_url`) — CI points both at a mock that
8//! writes into BIND.
9
10use std::time::Duration;
11
12const REQUEST_TIMEOUT_MS: u64 = 15_000;
13/// TTL of the challenge record.
14const RECORD_TTL: u32 = 60;
15
16/// Cloudflare's API base URL.
17pub const CLOUDFLARE_API: &str = "https://api.cloudflare.com/client/v4";
18/// Route 53's API endpoint (a global service, signed for us-east-1).
19pub const ROUTE53_API: &str = "https://route53.amazonaws.com";
20
21/// A secret string: `Debug` doesn't show it.
22#[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/// AWS credentials for Route 53.
32#[derive(Clone)]
33pub struct AwsCredentials {
34    pub access_key_id: String,
35    pub secret_access_key: String,
36    /// For temporary credentials (STS, SSO, an instance role exported to
37    /// the environment).
38    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    /// From `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and (optional)
49    /// `AWS_SESSION_TOKEN`.
50    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/// The record name's parents that could be its zone, longest first
74/// (`_acme-challenge.a.example.com` → `a.example.com`, `example.com`).
75#[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
81// ── Cloudflare ────────────────────────────────────────────────────────────────
82
83fn 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
87/// A Cloudflare reply: its body when it says `"success": true`.
88fn 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
110/// The ids of the TXT records at `name` (with content `value`, if given).
111async 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/// Replace the TXT records at `name` with `value`.
126#[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/// Remove the TXT record `value` at `name`.
142#[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// ── Route 53 ──────────────────────────────────────────────────────────────────
152
153/// The text of the first `<tag>` element.
154#[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
162/// The host part of a URL (no scheme, port or path) — what the HTTP client
163/// sends as `Host`, so what is signed.
164/// The `Host` header the HTTP client sends for `url` — the host, with the
165/// port when it isn't the scheme's default (since 18.21.0 the client sends
166/// it, so SigV4 must sign the same value).
167fn 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/// A `ChangeResourceRecordSets` body for one TXT change.
203#[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/// Apply `action` (`UPSERT`: replace the TXT records at `name` with
221/// `value`; `DELETE`: remove it) and, for an UPSERT, wait up to `wait` for
222/// Route 53 to report the change in sync.
223#[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/// `YYYYMMDDTHHMMSSZ` for a Unix time.
246#[doc(hidden)]
247pub fn amz_date(unix: u64) -> String {
248    let (days, secs) = (unix / 86_400, unix % 86_400);
249    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
250    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/// The headers that sign a request with AWS Signature Version 4: `X-Amz-Date`,
276/// `X-Amz-Security-Token` (temporary credentials) and `Authorization`.
277/// `query` is the canonical query string (parameters sorted, values
278/// URI-encoded) — what is sent.
279#[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    /// AWS's Signature Version 4 test suite, `get-vanilla`.
331    #[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        // The Host header the client sends: a non-default port included.
379        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}