use crate::crypto::{hmac_sha256, sha256_digest};
use crate::http::{HttpClient, HttpClientBuilder};
use crate::utils::txt_chunks_to_text;
use crate::{
CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue as DnsKeyValue, MXRecord,
Result, SRVRecord,
};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const DEFAULT_API_BASE: &str = "/config-dns/v2";
const DEFAULT_MAX_BODY: usize = 131072;
#[derive(Debug, Clone)]
pub struct EdgeDnsConfig {
pub host: String,
pub client_token: String,
pub client_secret: String,
pub access_token: String,
pub account_switch_key: Option<String>,
pub request_timeout: Option<Duration>,
}
#[derive(Clone)]
pub struct EdgeDnsProvider {
client: HttpClient,
host: String,
scheme: String,
base_path: String,
client_token: String,
client_secret: String,
access_token: String,
account_switch_key: Option<String>,
max_body: usize,
}
#[derive(Serialize, Debug)]
struct RecordBody<'a> {
name: &'a str,
#[serde(rename = "type")]
record_type: &'a str,
ttl: u32,
rdata: Vec<String>,
}
impl EdgeDnsProvider {
pub(crate) fn new(config: EdgeDnsConfig) -> Result<Self> {
if config.host.is_empty() {
return Err(Error::Client("edgedns: host is required".to_string()));
}
if config.client_token.is_empty()
|| config.client_secret.is_empty()
|| config.access_token.is_empty()
{
return Err(Error::Client(
"edgedns: client_token, client_secret and access_token are required".to_string(),
));
}
let client = HttpClientBuilder::default()
.with_timeout(config.request_timeout)
.build();
let (host, scheme) = parse_host(&config.host);
Ok(Self {
client,
host,
scheme,
base_path: DEFAULT_API_BASE.to_string(),
client_token: config.client_token,
client_secret: config.client_secret,
access_token: config.access_token,
account_switch_key: config.account_switch_key,
max_body: DEFAULT_MAX_BODY,
})
}
#[cfg(test)]
pub(crate) fn with_endpoint(mut self, endpoint: impl AsRef<str>) -> Self {
let endpoint = endpoint.as_ref().trim_end_matches('/').to_string();
let (host, scheme) = parse_host(&endpoint);
self.host = host;
self.scheme = scheme;
self
}
fn base_url(&self) -> String {
format!("{}://{}{}", self.scheme, self.host, self.base_path)
}
pub(crate) async fn set_rrset(
&self,
name: impl IntoFqdn<'_>,
record_type: DnsRecordType,
ttl: u32,
records: Vec<DnsRecord>,
origin: impl IntoFqdn<'_>,
) -> Result<()> {
check_record_types(record_type, &records)?;
let name = name.into_name().to_ascii_lowercase();
let zone = origin.into_name().to_ascii_lowercase();
if zone.is_empty() {
return Err(Error::Api("edgedns: origin zone is required".to_string()));
}
let type_str = edgedns_record_type(record_type)?;
let path = self.record_path(&zone, &name, type_str);
let url = format!("{}{}", self.base_url(), path);
if records.is_empty() {
match self.send("DELETE", &url, None).await {
Ok(_) => return Ok(()),
Err(Error::NotFound) => return Ok(()),
Err(e) => return Err(e),
}
}
let rdata = build_rdata(record_type, &records)?;
let body = RecordBody {
name: &name,
record_type: type_str,
ttl,
rdata,
};
let payload =
serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
match self.send("PUT", &url, Some(&payload)).await {
Ok(_) => Ok(()),
Err(Error::NotFound) => {
self.send("POST", &url, Some(&payload)).await?;
Ok(())
}
Err(e) => Err(e),
}
}
pub(crate) async fn add_to_rrset(
&self,
name: impl IntoFqdn<'_>,
record_type: DnsRecordType,
ttl: u32,
records: Vec<DnsRecord>,
origin: impl IntoFqdn<'_>,
) -> Result<()> {
check_record_types(record_type, &records)?;
if records.is_empty() {
return Ok(());
}
let name = name.into_name().to_ascii_lowercase();
let zone = origin.into_name().to_ascii_lowercase();
if zone.is_empty() {
return Err(Error::Api("edgedns: origin zone is required".to_string()));
}
let type_str = edgedns_record_type(record_type)?;
let path = self.record_path(&zone, &name, type_str);
let url = format!("{}{}", self.base_url(), path);
let current = self.fetch_rdata(&url).await?;
let desired = build_rdata(record_type, &records)?;
let mut merged = current;
for entry in desired {
if !merged.iter().any(|existing| existing == &entry) {
merged.push(entry);
}
}
let body = RecordBody {
name: &name,
record_type: type_str,
ttl,
rdata: merged,
};
let payload =
serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
match self.send("PUT", &url, Some(&payload)).await {
Ok(_) => Ok(()),
Err(Error::NotFound) => {
self.send("POST", &url, Some(&payload)).await?;
Ok(())
}
Err(e) => Err(e),
}
}
pub(crate) async fn remove_from_rrset(
&self,
name: impl IntoFqdn<'_>,
record_type: DnsRecordType,
records: Vec<DnsRecord>,
origin: impl IntoFqdn<'_>,
) -> Result<()> {
check_record_types(record_type, &records)?;
if records.is_empty() {
return Ok(());
}
let name = name.into_name().to_ascii_lowercase();
let zone = origin.into_name().to_ascii_lowercase();
if zone.is_empty() {
return Err(Error::Api("edgedns: origin zone is required".to_string()));
}
let type_str = edgedns_record_type(record_type)?;
let path = self.record_path(&zone, &name, type_str);
let url = format!("{}{}", self.base_url(), path);
let current = self.fetch_rrset(&url).await?;
let Some(existing) = current else {
return Ok(());
};
let to_remove = build_rdata(record_type, &records)?;
let remaining: Vec<String> = existing
.rdata
.into_iter()
.filter(|entry| !to_remove.iter().any(|drop| drop == entry))
.collect();
if remaining.is_empty() {
match self.send("DELETE", &url, None).await {
Ok(_) => return Ok(()),
Err(Error::NotFound) => return Ok(()),
Err(e) => return Err(e),
}
}
let body = RecordBody {
name: &name,
record_type: type_str,
ttl: existing.ttl,
rdata: remaining,
};
let payload =
serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
self.send("PUT", &url, Some(&payload)).await?;
Ok(())
}
pub(crate) async fn list_rrset(
&self,
name: impl IntoFqdn<'_>,
record_type: DnsRecordType,
origin: impl IntoFqdn<'_>,
) -> Result<Vec<DnsRecord>> {
let name = name.into_name().to_ascii_lowercase();
let zone = origin.into_name().to_ascii_lowercase();
if zone.is_empty() {
return Err(Error::Api("edgedns: origin zone is required".to_string()));
}
let type_str = edgedns_record_type(record_type)?;
let path = self.record_path(&zone, &name, type_str);
let url = format!("{}{}", self.base_url(), path);
let Some(current) = self.fetch_rrset(&url).await? else {
return Ok(Vec::new());
};
let mut out = Vec::with_capacity(current.rdata.len());
for entry in current.rdata {
out.push(rdata_to_record(record_type, &entry)?);
}
Ok(out)
}
async fn fetch_rrset(&self, url: &str) -> Result<Option<RecordResponse>> {
match self.send("GET", url, None).await {
Ok(text) => {
let parsed: RecordResponse = serde_json::from_str(&text)
.map_err(|e| Error::Parse(format!("edgedns rrset parse: {e}")))?;
Ok(Some(parsed))
}
Err(Error::NotFound) => Ok(None),
Err(e) => Err(e),
}
}
async fn fetch_rdata(&self, url: &str) -> Result<Vec<String>> {
Ok(self
.fetch_rrset(url)
.await?
.map(|r| r.rdata)
.unwrap_or_default())
}
fn record_path(&self, zone: &str, name: &str, record_type: &str) -> String {
format!(
"/zones/{}/names/{}/types/{}",
url_encode(zone),
url_encode(name),
record_type
)
}
async fn send(&self, method: &str, url: &str, body: Option<&str>) -> Result<String> {
let parsed = url
.parse::<reqwest::Url>()
.map_err(|e| Error::Client(format!("edgedns url parse: {e}")))?;
let path_query = match parsed.query() {
Some(q) => format!("{}?{}", parsed.path(), q),
None => parsed.path().to_string(),
};
let host = match parsed.port() {
Some(p) => format!("{}:{}", parsed.host_str().unwrap_or(""), p),
None => parsed.host_str().unwrap_or("").to_string(),
};
let scheme = parsed.scheme().to_string();
let timestamp = Utc::now().format("%Y%m%dT%H:%M:%S+0000").to_string();
let nonce = generate_nonce();
let body_for_hash = if matches!(method, "POST" | "PUT") {
body.unwrap_or("").as_bytes()
} else {
&[][..]
};
let content_hash = if body_for_hash.is_empty() {
String::new()
} else {
let truncated = if body_for_hash.len() > self.max_body {
&body_for_hash[..self.max_body]
} else {
body_for_hash
};
BASE64_STANDARD.encode(sha256_digest(truncated))
};
let auth_without_signature = format!(
"EG1-HMAC-SHA256 client_token={};access_token={};timestamp={};nonce={};",
self.client_token, self.access_token, timestamp, nonce
);
let canonical_headers = String::new();
let data_to_sign = format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
method.to_ascii_uppercase(),
scheme,
host,
path_query,
canonical_headers,
content_hash,
auth_without_signature
);
let signing_key = BASE64_STANDARD.encode(hmac_sha256(
self.client_secret.as_bytes(),
timestamp.as_bytes(),
));
let signature =
BASE64_STANDARD.encode(hmac_sha256(signing_key.as_bytes(), data_to_sign.as_bytes()));
let authorization = format!("{}signature={}", auth_without_signature, signature);
let mut request = match method {
"GET" => self.client.get(url),
"POST" => self.client.post(url),
"PUT" => self.client.put(url),
"DELETE" => self.client.delete(url),
other => {
return Err(Error::Unsupported(format!(
"edgedns unsupported method: {other}"
)));
}
};
request = request
.with_header("Authorization", authorization)
.with_header("Accept", "application/json");
if let Some(asw) = &self.account_switch_key {
request = request.with_header("X-AccountSwitchKey", asw);
}
if let Some(body) = body {
request = request
.with_header("Content-Type", "application/json")
.with_raw_body(body.to_string());
}
request.send_raw().await
}
}
fn parse_host(input: &str) -> (String, String) {
let trimmed = input.trim_end_matches('/');
if let Some(rest) = trimmed.strip_prefix("https://") {
return (rest.to_string(), "https".to_string());
}
if let Some(rest) = trimmed.strip_prefix("http://") {
return (rest.to_string(), "http".to_string());
}
(trimmed.to_string(), "https".to_string())
}
fn url_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.as_bytes() {
let c = *byte as char;
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
out.push(c);
} else {
out.push_str(&format!("%{:02X}", byte));
}
}
out
}
fn generate_nonce() -> String {
let now = Utc::now();
let nanos = now.timestamp_nanos_opt().unwrap_or(now.timestamp());
let mut buf = [0u8; 16];
let bytes = (nanos as u128).to_le_bytes();
buf.copy_from_slice(&bytes);
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
u32::from_le_bytes(buf[0..4].try_into().unwrap()),
u16::from_le_bytes(buf[4..6].try_into().unwrap()),
u16::from_le_bytes(buf[6..8].try_into().unwrap()),
u16::from_le_bytes(buf[8..10].try_into().unwrap()),
{
let mut full = [0u8; 8];
full[2..8].copy_from_slice(&buf[10..16]);
u64::from_be_bytes(full)
}
)
}
fn edgedns_record_type(record_type: DnsRecordType) -> Result<&'static str> {
match record_type {
DnsRecordType::A => Ok("A"),
DnsRecordType::AAAA => Ok("AAAA"),
DnsRecordType::CNAME => Ok("CNAME"),
DnsRecordType::NS => Ok("NS"),
DnsRecordType::MX => Ok("MX"),
DnsRecordType::TXT => Ok("TXT"),
DnsRecordType::SRV => Ok("SRV"),
DnsRecordType::CAA => Ok("CAA"),
DnsRecordType::TLSA => Err(Error::Unsupported(
"TLSA records are not supported by EdgeDNS".to_string(),
)),
}
}
struct EdgeDnsRecord {
record_type: String,
rdata: Vec<String>,
}
impl TryFrom<&DnsRecord> for EdgeDnsRecord {
type Error = Error;
fn try_from(record: &DnsRecord) -> Result<Self> {
Ok(match record {
DnsRecord::A(addr) => Self {
record_type: "A".to_string(),
rdata: vec![addr.to_string()],
},
DnsRecord::AAAA(addr) => Self {
record_type: "AAAA".to_string(),
rdata: vec![addr.to_string()],
},
DnsRecord::CNAME(value) => Self {
record_type: "CNAME".to_string(),
rdata: vec![ensure_dot(value)],
},
DnsRecord::NS(value) => Self {
record_type: "NS".to_string(),
rdata: vec![ensure_dot(value)],
},
DnsRecord::MX(MXRecord { priority, exchange }) => Self {
record_type: "MX".to_string(),
rdata: vec![format!("{} {}", priority, ensure_dot(exchange))],
},
DnsRecord::TXT(value) => Self {
record_type: "TXT".to_string(),
rdata: vec![{
let mut out = String::new();
txt_chunks_to_text(&mut out, value, " ");
out
}],
},
DnsRecord::SRV(SRVRecord {
target,
priority,
weight,
port,
}) => Self {
record_type: "SRV".to_string(),
rdata: vec![format!(
"{} {} {} {}",
priority,
weight,
port,
ensure_dot(target)
)],
},
DnsRecord::CAA(caa) => Self {
record_type: "CAA".to_string(),
rdata: vec![caa_to_rdata(caa)],
},
DnsRecord::TLSA(_) => {
return Err(Error::Unsupported(
"TLSA records are not supported by EdgeDNS".to_string(),
));
}
})
}
}
fn caa_to_rdata(caa: &CAARecord) -> String {
match caa {
CAARecord::Issue {
issuer_critical,
name,
options,
} => {
let flags = if *issuer_critical { 128 } else { 0 };
let mut value = name.clone().unwrap_or_default();
for opt in options {
value.push_str(&format!(";{}", opt));
}
format!("{} issue \"{}\"", flags, value)
}
CAARecord::IssueWild {
issuer_critical,
name,
options,
} => {
let flags = if *issuer_critical { 128 } else { 0 };
let mut value = name.clone().unwrap_or_default();
for opt in options {
value.push_str(&format!(";{}", opt));
}
format!("{} issuewild \"{}\"", flags, value)
}
CAARecord::Iodef {
issuer_critical,
url,
} => {
let flags = if *issuer_critical { 128 } else { 0 };
format!("{} iodef \"{}\"", flags, url)
}
}
}
fn ensure_dot(value: &str) -> String {
if value.ends_with('.') {
value.to_string()
} else {
format!("{}.", value)
}
}
#[derive(Deserialize)]
struct RecordResponse {
#[serde(default)]
ttl: u32,
#[serde(default)]
rdata: Vec<String>,
}
fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> Result<()> {
for r in records {
if r.as_type() != expected {
return Err(Error::Api(format!(
"RRSet record type mismatch: expected {}, got {}",
expected.as_str(),
r.as_type().as_str(),
)));
}
}
Ok(())
}
fn build_rdata(record_type: DnsRecordType, records: &[DnsRecord]) -> Result<Vec<String>> {
let mut out = Vec::with_capacity(records.len());
for record in records {
let representation = EdgeDnsRecord::try_from(record)?;
let expected_type = edgedns_record_type(record_type)?;
if representation.record_type != expected_type {
return Err(Error::Api(format!(
"RRSet record type mismatch: expected {}, got {}",
expected_type, representation.record_type,
)));
}
for entry in representation.rdata {
out.push(entry);
}
}
Ok(out)
}
fn rdata_to_record(record_type: DnsRecordType, entry: &str) -> Result<DnsRecord> {
match record_type {
DnsRecordType::A => entry
.parse()
.map(DnsRecord::A)
.map_err(|e| Error::Parse(format!("edgedns A rdata: {e}"))),
DnsRecordType::AAAA => entry
.parse()
.map(DnsRecord::AAAA)
.map_err(|e| Error::Parse(format!("edgedns AAAA rdata: {e}"))),
DnsRecordType::CNAME => Ok(DnsRecord::CNAME(strip_trailing_dot(entry))),
DnsRecordType::NS => Ok(DnsRecord::NS(strip_trailing_dot(entry))),
DnsRecordType::MX => {
let (priority_str, exchange) = entry
.split_once(' ')
.ok_or_else(|| Error::Parse(format!("edgedns MX rdata: {entry}")))?;
let priority: u16 = priority_str
.parse()
.map_err(|e| Error::Parse(format!("edgedns MX priority: {e}")))?;
Ok(DnsRecord::MX(MXRecord {
priority,
exchange: strip_trailing_dot(exchange.trim()),
}))
}
DnsRecordType::TXT => Ok(DnsRecord::TXT(parse_txt_rdata(entry))),
DnsRecordType::SRV => {
let mut parts = entry.split_whitespace();
let priority: u16 = parts
.next()
.ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
.parse()
.map_err(|e| Error::Parse(format!("edgedns SRV priority: {e}")))?;
let weight: u16 = parts
.next()
.ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
.parse()
.map_err(|e| Error::Parse(format!("edgedns SRV weight: {e}")))?;
let port: u16 = parts
.next()
.ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
.parse()
.map_err(|e| Error::Parse(format!("edgedns SRV port: {e}")))?;
let target = parts
.next()
.ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?;
Ok(DnsRecord::SRV(SRVRecord {
priority,
weight,
port,
target: strip_trailing_dot(target),
}))
}
DnsRecordType::CAA => parse_caa_rdata(entry),
DnsRecordType::TLSA => Err(Error::Unsupported(
"TLSA records are not supported by EdgeDNS".to_string(),
)),
}
}
fn strip_trailing_dot(value: &str) -> String {
value.trim_end_matches('.').to_string()
}
fn parse_txt_rdata(entry: &str) -> String {
let trimmed = entry.trim();
let mut out = String::new();
let chars = trimmed.chars().peekable();
let mut in_quotes = false;
let mut escape = false;
for ch in chars {
if escape {
out.push(ch);
escape = false;
continue;
}
match ch {
'\\' if in_quotes => escape = true,
'"' => in_quotes = !in_quotes,
_ if in_quotes => out.push(ch),
_ => {}
}
}
if out.is_empty() {
trimmed.to_string()
} else {
out
}
}
fn parse_caa_rdata(entry: &str) -> Result<DnsRecord> {
let mut parts = entry.splitn(3, ' ');
let flags_str = parts
.next()
.ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
let tag = parts
.next()
.ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
let value_quoted = parts
.next()
.ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
let flags: u8 = flags_str
.parse()
.map_err(|e| Error::Parse(format!("edgedns CAA flags: {e}")))?;
let issuer_critical = flags & 128 != 0;
let value = value_quoted
.trim()
.trim_start_matches('"')
.trim_end_matches('"')
.to_string();
match tag {
"issue" => {
let (name, options) = parse_caa_value(&value);
Ok(DnsRecord::CAA(CAARecord::Issue {
issuer_critical,
name,
options,
}))
}
"issuewild" => {
let (name, options) = parse_caa_value(&value);
Ok(DnsRecord::CAA(CAARecord::IssueWild {
issuer_critical,
name,
options,
}))
}
"iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
issuer_critical,
url: value,
})),
other => Err(Error::Unsupported(format!(
"edgedns CAA tag unsupported: {other}"
))),
}
}
fn parse_caa_value(value: &str) -> (Option<String>, Vec<DnsKeyValue>) {
let mut parts = value.split(';').map(str::trim);
let head = parts.next().unwrap_or("").to_string();
let name = if head.is_empty() { None } else { Some(head) };
let options = parts
.filter(|p| !p.is_empty())
.map(|p| match p.split_once('=') {
Some((k, v)) => DnsKeyValue {
key: k.trim().to_string(),
value: v.trim().to_string(),
},
None => DnsKeyValue {
key: p.trim().to_string(),
value: String::new(),
},
})
.collect();
(name, options)
}