diff --git a/Cargo.lock b/Cargo.lock index 0fe7de81..79372c61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1179,7 +1179,10 @@ dependencies = [ "didbot-identity", "hex", "hmac", + "quick-xml", "reqwest", + "serde", + "serde_json", "sha2", "thiserror", "time", @@ -3594,6 +3597,16 @@ dependencies = [ "cc", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.11" diff --git a/Cargo.toml b/Cargo.toml index 5f309a84..27e91e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,9 @@ k256 = { version = "0.13", features = ["ecdsa"] } # Verification only, and never signing: see the `secp256r1` module in # `didbot-key` for why this project mints no P-256 key. p256 = { version = "0.13", features = ["ecdsa"] } +# Route53's control-plane API speaks XML, and this crate reads and writes it +# through serde like everything else here reads JSON. +quick-xml = { version = "0.42", features = ["serialize"] } rand = "0.9" rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index 34380b33..5dd01e61 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -7627,6 +7627,37 @@ SOFTWARE. MIT License ================================================================================ +Applies to: + * quick-xml 0.42.0 -- https://github.com/tafia/quick-xml + +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ +MIT License +================================================================================ + Applies to: * pem 4.0.0 -- https://github.com/jcreekmore/pem-rs.git diff --git a/crates/didbot-dns/Cargo.toml b/crates/didbot-dns/Cargo.toml index db7ba161..06e15d11 100644 --- a/crates/didbot-dns/Cargo.toml +++ b/crates/didbot-dns/Cargo.toml @@ -12,7 +12,7 @@ publish.workspace = true # Off by default: a build that only ever runs `InMemoryDns` (every test binary # in this workspace, and any deployment on `*.localhost`) should not pay to # link a blocking HTTP client and a hand-rolled AWS signer it never calls. -route53 = ["dep:reqwest", "dep:didbot-http", "didbot-http/blocking", "dep:hmac", "dep:sha2", "dep:hex", "dep:time"] +route53 = ["dep:reqwest", "dep:didbot-http", "didbot-http/blocking", "dep:hmac", "dep:sha2", "dep:hex", "dep:time", "dep:quick-xml", "dep:serde", "dep:serde_json"] # The in-process AWS stand-in in `aws_fake`, for tests in this crate and in # any crate that drives a `Route53Dns` — turned on under `[dev-dependencies]`, # so a production build links none of it. @@ -30,6 +30,9 @@ hmac = { workspace = true, optional = true } sha2 = { workspace = true, optional = true } hex = { workspace = true, optional = true } time = { workspace = true, optional = true } +quick-xml = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } [dev-dependencies] # Only to prove, in `route53.rs`'s own tests, that a caller of `publish_txt` diff --git a/crates/didbot-dns/src/route53.rs b/crates/didbot-dns/src/route53.rs index 47b43aaf..7bf03a58 100644 --- a/crates/didbot-dns/src/route53.rs +++ b/crates/didbot-dns/src/route53.rs @@ -14,14 +14,10 @@ //! operations, one signing scheme. `Cargo.lock` already carries everything //! signing it needs — `reqwest` (with `rustls-tls`, already selected), `hmac` //! and `sha2` (both used elsewhere in this workspace for other purposes) — -//! so the entire addition for this feature is `reqwest`'s `blocking` client -//! feature, `hex` and `time`, all either already present or trivial. No new -//! crate enters the lock file. The tradeoff is that this module owns request -//! and response XML: it hand-writes the handful of request bodies Route53 -//! needs and parses only the handful of response shapes this provider reads -//! — a purpose-built extractor for those specific shapes, not a -//! general-purpose XML library, documented further on the private `xml` -//! module this file defines. +//! so the addition for this feature is `reqwest`'s `blocking` client feature, +//! `hex`, `time` and `quick-xml`. The request bodies and the response shapes +//! this provider reads are serde types on the private `xml` module this file +//! defines, so the XML itself is `quick-xml`'s to escape and to parse. //! //! # Eventual consistency: a challenge publish waits, a withdrawal does not //! @@ -379,24 +375,30 @@ fn imds_request(request: reqwest::blocking::RequestBuilder) -> Result` answers with. +/// `Code`, `LastUpdated` and `Type` are in the response and are not read. +#[derive(serde::Deserialize)] +struct ImdsCredentials { + #[serde(rename = "AccessKeyId")] + access_key_id: String, + #[serde(rename = "SecretAccessKey")] + secret_access_key: String, + #[serde(rename = "Token")] + token: Option, + #[serde(rename = "Expiration")] + expiration: Option, +} + /// Reads the credentials out of a /// `GET /latest/meta-data/iam/security-credentials/` response. -/// -/// Purpose-built for that endpoint's fixed, flat JSON shape — `AccessKeyId`, -/// `SecretAccessKey` and `Token` as top-level string fields — the same trade -/// the `xml` module below makes for Route53's responses: a small extractor -/// for a known shape, not a general JSON parser. fn parse_imds_credentials(body: &str) -> Result { - let access_key_id = json_string_field(body, "AccessKeyId").ok_or_else(|| { - ImdsError::Response("no AccessKeyId in the credentials response".to_string()) - })?; - let secret_access_key = json_string_field(body, "SecretAccessKey").ok_or_else(|| { - ImdsError::Response("no SecretAccessKey in the credentials response".to_string()) + let parsed: ImdsCredentials = serde_json::from_str(body).map_err(|err| { + ImdsError::Response(format!("the credentials response does not read: {err}")) })?; - let session_token = json_string_field(body, "Token"); - let expires_at = match json_string_field(body, "Expiration") { + let expires_at = match &parsed.expiration { Some(stated) => Some( - time::OffsetDateTime::parse(&stated, &time::format_description::well_known::Rfc3339) + time::OffsetDateTime::parse(stated, &time::format_description::well_known::Rfc3339) .map_err(|err| { ImdsError::Response(format!("Expiration {stated:?} is not a timestamp: {err}")) })?, @@ -405,25 +407,14 @@ fn parse_imds_credentials(body: &str) -> Result { }; Ok(TimedCredentials { credentials: Route53Credentials { - access_key_id, - secret_access_key, - session_token, + access_key_id: parsed.access_key_id, + secret_access_key: parsed.secret_access_key, + session_token: parsed.token, }, expires_at, }) } -/// The string value of `"key": "value"` in a flat JSON object. -fn json_string_field(body: &str, key: &str) -> Option { - let needle = format!("\"{key}\""); - let after_key = &body[body.find(&needle)? + needle.len()..]; - let after_colon = &after_key[after_key.find(':')? + 1..]; - let quoted = after_colon.trim_start(); - let inner = quoted.strip_prefix('"')?; - let end = inner.find('"')?; - Some(inner[..end].to_string()) -} - /// How this provider retries a throttled or transiently failed call. #[derive(Debug, Clone, Copy)] pub struct RetryPolicy { @@ -667,9 +658,8 @@ impl Route53Dns { ttl: u32, values: &[String], ) -> Result { - let mut body = xml::change_batch_open("didbot"); - body.push_str(&xml::change(action, host, rtype, ttl, values)); - body.push_str(xml::CHANGE_BATCH_CLOSE); + let body = xml::change_request("didbot", action, host, rtype, ttl, values) + .map_err(|err| self.backend_error(host, format!("cannot build the change: {err}")))?; let path = format!("/{API_VERSION}/hostedzone/{}/rrset", self.hosted_zone_id); let response = self.call_with_retry("POST", &path, &[], body, host)?; xml::parse_change_id(&response).ok_or_else(|| { @@ -1033,39 +1023,98 @@ mod sigv4 { /// CDATA or non-well-formed input, none of which Route53's own responses use /// in the elements this module reads. mod xml { - pub(super) const CHANGE_BATCH_CLOSE: &str = - ""; - - pub(super) fn change_batch_open(comment: &str) -> String { - format!( - "\ - {}", - super::API_VERSION, - escape(comment) - ) + use serde::{Deserialize, Serialize}; + + /// One `ChangeResourceRecordSets` request body. + #[derive(Serialize)] + #[serde(rename = "ChangeResourceRecordSetsRequest")] + struct ChangeRequest { + #[serde(rename = "@xmlns")] + xmlns: String, + #[serde(rename = "ChangeBatch")] + batch: ChangeBatch, + } + + #[derive(Serialize)] + struct ChangeBatch { + #[serde(rename = "Comment")] + comment: String, + #[serde(rename = "Changes")] + changes: Changes, + } + + #[derive(Serialize)] + struct Changes { + #[serde(rename = "Change")] + change: Vec, + } + + #[derive(Serialize)] + struct Change { + #[serde(rename = "Action")] + action: String, + #[serde(rename = "ResourceRecordSet")] + record_set: ResourceRecordSet, + } + + #[derive(Serialize)] + struct ResourceRecordSet { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "Type")] + rtype: String, + #[serde(rename = "TTL")] + ttl: u32, + #[serde(rename = "ResourceRecords")] + records: ResourceRecords, } - pub(super) fn change( + #[derive(Serialize)] + struct ResourceRecords { + #[serde(rename = "ResourceRecord")] + record: Vec, + } + + #[derive(Serialize)] + struct ResourceRecord { + #[serde(rename = "Value")] + value: String, + } + + /// The body of a one-change `ChangeResourceRecordSets` call. + pub(super) fn change_request( + comment: &str, action: &str, host: &str, rtype: &str, ttl: u32, values: &[String], - ) -> String { - let mut records = String::new(); - for value in values { - records.push_str(&format!( - "{}", - escape(value) - )); - } - format!( - "{action}\ - {}{rtype}{ttl}\ - {records}\ - ", - escape(host) - ) + ) -> Result { + let request = ChangeRequest { + xmlns: format!("https://route53.amazonaws.com/doc/{}/", super::API_VERSION), + batch: ChangeBatch { + comment: comment.to_string(), + changes: Changes { + change: vec![Change { + action: action.to_string(), + record_set: ResourceRecordSet { + name: host.to_string(), + rtype: rtype.to_string(), + ttl, + records: ResourceRecords { + record: values + .iter() + .map(|value| ResourceRecord { + value: value.clone(), + }) + .collect(), + }, + }, + }], + }, + }, + }; + quick_xml::se::to_string(&request) } /// Wraps a TXT value in the quoted, backslash-escaped form the DNS @@ -1083,54 +1132,56 @@ mod xml { out } - fn escape(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") + /// What `ChangeResourceRecordSets` and `GetChange` both answer with. + #[derive(Deserialize)] + struct ChangeInfoResponse { + #[serde(rename = "ChangeInfo")] + change_info: ChangeInfo, } - fn unescape(value: &str) -> String { - value - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - .replace("&", "&") + #[derive(Deserialize)] + struct ChangeInfo { + #[serde(rename = "Id")] + id: String, + #[serde(rename = "Status")] + status: String, } - /// Text content of the first `...` at or after `from`, plus - /// the byte offset just past its closing tag. Depth-unaware: fine for - /// every leaf this module reads (`Id`, `Status`, `Name`, `Type`, - /// `Value`, error `Code`/`Message`), wrong for a container tag that can - /// nest inside itself, which none of the tags this module reads do. - fn extract(xml: &str, from: usize, tag: &str) -> Option<(String, usize)> { - let open = format!("<{tag}>"); - let close = format!(""); - let start = xml[from..].find(&open)? + from + open.len(); - let end = xml[start..].find(&close)? + start; - Some((unescape(&xml[start..end]), end + close.len())) + #[derive(Deserialize)] + struct ErrorResponse { + #[serde(rename = "Error")] + error: ApiError, } - pub(super) fn parse_change_id(xml: &str) -> Option { - extract(xml, 0, "Id").map(|(v, _)| v.trim_start_matches("/change/").to_string()) + /// What an API error says about itself. `Message` is optional because a + /// few of Route53's errors carry only a code. + #[derive(Deserialize)] + pub(super) struct ApiError { + #[serde(rename = "Code")] + pub(super) code: String, + #[serde(rename = "Message", default)] + pub(super) message: String, } - pub(super) fn parse_status(xml: &str) -> Option { - extract(xml, 0, "Status").map(|(v, _)| v) + pub(super) fn parse_change_id(xml: &str) -> Option { + let response: ChangeInfoResponse = quick_xml::de::from_str(xml).ok()?; + Some( + response + .change_info + .id + .trim_start_matches("/change/") + .to_string(), + ) } - pub(super) struct ApiError { - pub(super) code: String, - pub(super) message: String, + pub(super) fn parse_status(xml: &str) -> Option { + let response: ChangeInfoResponse = quick_xml::de::from_str(xml).ok()?; + Some(response.change_info.status) } pub(super) fn parse_error(xml: &str) -> Option { - let (code, after) = extract(xml, 0, "Code")?; - let (message, _) = extract(xml, after, "Message").unwrap_or((String::new(), after)); - Some(ApiError { code, message }) + let response: ErrorResponse = quick_xml::de::from_str(xml).ok()?; + Some(response.error) } } @@ -1300,13 +1351,11 @@ mod tests { assert!(body.contains("UPSERT"), "{body}"); assert!(body.contains("CAA"), "{body}"); assert!( - body.contains( - "0 issue "letsencrypt.org; accounturi=https://acme.example/acct/1"" - ), + body.contains("0 issue \"letsencrypt.org; accounturi=https://acme.example/acct/1\""), "{body}" ); assert!( - body.contains("0 iodef "mailto:ops@example.com""), + body.contains("0 iodef \"mailto:ops@example.com\""), "{body}" ); }