use std::collections::{BTreeSet, HashMap};
use std::sync::Mutex;
use async_trait::async_trait;
use aws_sdk_route53::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_route53::types::{
Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet, RrType,
};
use aws_sdk_route53::Client;
use aws_smithy_http_client::tls::rustls_provider::CryptoMode;
use aws_smithy_http_client::tls::Provider;
use super::{DnsError, DnsProvider, TxtHandle};
pub struct Route53Dns {
client: Client,
zone_id: String,
published: Mutex<HashMap<String, BTreeSet<String>>>,
}
impl Route53Dns {
pub fn new(
access_key_id: impl Into<String>,
secret_access_key: impl Into<String>,
session_token: Option<String>,
region: impl Into<String>,
zone_id: impl Into<String>,
) -> Self {
let creds = Credentials::new(
access_key_id.into(),
secret_access_key.into(),
session_token,
None,
"waf-route53",
);
let http = aws_smithy_http_client::Builder::new()
.tls_provider(Provider::Rustls(CryptoMode::Ring))
.build_https();
let conf = aws_sdk_route53::Config::builder()
.behavior_version(BehaviorVersion::latest())
.region(Region::new(region.into()))
.credentials_provider(creds)
.http_client(http)
.build();
Self {
client: Client::from_conf(conf),
zone_id: zone_id.into(),
published: Mutex::new(HashMap::new()),
}
}
async fn apply(&self, action: ChangeAction, name: &str, values: &[String]) -> Result<(), DnsError> {
let records = values
.iter()
.map(|v| ResourceRecord::builder().value(format!("\"{v}\"")).build())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| DnsError::Api(e.to_string()))?;
let rrset = ResourceRecordSet::builder()
.name(name)
.r#type(RrType::Txt)
.ttl(60)
.set_resource_records(Some(records))
.build()
.map_err(|e| DnsError::Api(e.to_string()))?;
let change = Change::builder()
.action(action)
.resource_record_set(rrset)
.build()
.map_err(|e| DnsError::Api(e.to_string()))?;
let batch = ChangeBatch::builder()
.changes(change)
.build()
.map_err(|e| DnsError::Api(e.to_string()))?;
self.client
.change_resource_record_sets()
.hosted_zone_id(&self.zone_id)
.change_batch(batch)
.send()
.await
.map_err(|e| DnsError::Api(format!("route53 change failed: {e}")))?;
Ok(())
}
}
enum RemovePlan {
Noop,
Delete(Vec<String>),
Upsert(Vec<String>),
}
#[async_trait]
impl DnsProvider for Route53Dns {
async fn add_txt(&self, name: &str, value: &str) -> Result<TxtHandle, DnsError> {
let values = {
let mut pub_map = self.published.lock().unwrap();
let set = pub_map.entry(name.to_string()).or_default();
set.insert(value.to_string());
set.iter().cloned().collect::<Vec<_>>()
};
self.apply(ChangeAction::Upsert, name, &values).await?;
Ok(TxtHandle {
name: name.to_string(),
value: value.to_string(),
provider_ref: String::new(),
})
}
async fn remove_txt(&self, handle: &TxtHandle) -> Result<(), DnsError> {
let plan = {
let mut pub_map = self.published.lock().unwrap();
let decision = match pub_map.get_mut(&handle.name) {
Some(set) => {
set.remove(&handle.value);
if set.is_empty() {
RemovePlan::Delete(vec![handle.value.clone()])
} else {
RemovePlan::Upsert(set.iter().cloned().collect())
}
}
None => RemovePlan::Noop,
};
if let RemovePlan::Delete(_) = decision {
pub_map.remove(&handle.name);
}
decision
};
match plan {
RemovePlan::Noop => Ok(()),
RemovePlan::Delete(values) => self.apply(ChangeAction::Delete, &handle.name, &values).await,
RemovePlan::Upsert(values) => self.apply(ChangeAction::Upsert, &handle.name, &values).await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn builds_client_and_tracks_rrset_values() {
let p = Route53Dns::new("AKIAEXAMPLE", "secret", None, "us-east-1", "Z123456");
assert_eq!(p.zone_id, "Z123456");
let name = "_acme-challenge.example.com";
{
let mut m = p.published.lock().unwrap();
m.entry(name.to_string()).or_default().insert("apex".into());
m.entry(name.to_string()).or_default().insert("wild".into());
let set = &m[name];
assert!(set.contains("apex") && set.contains("wild"), "both values coexist on one name");
}
}
}