use crate::challenge::DnsProvider;
use crate::error::{AcmeError, Result};
use async_trait::async_trait;
use hmac::{Hmac, KeyInit, Mac};
use jiff::Zoned;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub struct HuaweiCloudDnsProvider {
access_key: String,
secret_key: String,
#[allow(dead_code)]
project_id: String,
region: String,
client: reqwest::Client,
}
impl HuaweiCloudDnsProvider {
pub fn new(access_key: String, secret_key: String, project_id: String, region: String) -> Self {
tracing::debug!("Initializing HuaweiCloudDnsProvider for region: {}", region);
Self {
access_key,
secret_key,
project_id,
region,
client: reqwest::Client::new(),
}
}
fn sign_request(&self, method: &str, url: &str, payload: &str) -> (String, String) {
let now = Zoned::now();
let timestamp = now.strftime("%Y%m%dT%H%M%SZ").to_string();
let mut hasher = Sha256::new();
hasher.update(payload.as_bytes());
let hashed_payload = hex::encode(hasher.finalize());
let canonical_request = format!(
"{}\n{}\n\nhost:dns.{}.myhuaweicloud.com\nx-sdk-date:{}\n\nhost;x-sdk-date\n{}",
method, url, self.region, timestamp, hashed_payload
);
let mut hasher = Sha256::new();
hasher.update(canonical_request.as_bytes());
let hashed_canonical = hex::encode(hasher.finalize());
let string_to_sign = format!("SDK-HMAC-SHA256\n{}\n{}", timestamp, hashed_canonical);
let mut mac = Hmac::<Sha256>::new_from_slice(self.secret_key.as_bytes())
.expect("HMAC can take key of any size");
mac.update(string_to_sign.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
(timestamp, signature)
}
async fn find_zone_id(&self, domain: &str) -> Result<String> {
let parts: Vec<&str> = domain.split('.').collect();
let zone_name = if parts.len() > 2 {
format!("{}.", parts[parts.len() - 2..].join("."))
} else {
format!("{}.", domain)
};
tracing::debug!("Searching for Huawei Cloud DNS zone: {}", zone_name);
let url = "/v2/zones";
let (timestamp, signature) = self.sign_request("GET", url, "");
let endpoint = format!("https://dns.{}.myhuaweicloud.com{}", self.region, url);
let response = self
.client
.get(&endpoint)
.header("X-Sdk-Date", timestamp)
.header(
"Authorization",
format!(
"SDK-HMAC-SHA256 Access={}, SignedHeaders=host;x-sdk-date, Signature={}",
self.access_key, signature
),
)
.send()
.await
.map_err(|e| {
tracing::error!("Network error while listing Huawei Cloud DNS zones: {}", e);
AcmeError::transport(format!("Huawei API list zones failed: {}", e))
})?;
let body: serde_json::Value = response.json().await.map_err(|e| {
tracing::error!("Failed to parse Huawei Cloud DNS zones response: {}", e);
AcmeError::protocol(format!("Failed to parse Huawei zones response: {}", e))
})?;
if let Some(zones) = body["zones"].as_array() {
for zone in zones {
if zone["name"].as_str() == Some(&zone_name) {
let id = zone["id"].as_str().unwrap_or_default().to_string();
tracing::debug!("Found Huawei zone ID: {} for {}", id, zone_name);
return Ok(id);
}
}
}
tracing::error!("Huawei Cloud DNS zone not found for: {}", zone_name);
Err(AcmeError::protocol(format!(
"Zone not found for domain: {}",
domain
)))
}
}
#[async_trait]
impl DnsProvider for HuaweiCloudDnsProvider {
async fn create_txt_record(&self, domain: &str, value: &str) -> Result<String> {
tracing::info!(
"Creating TXT record in Huawei Cloud DNS for domain: {}",
domain
);
let zone_id = self.find_zone_id(domain).await?;
let url = format!("/v2/zones/{}/recordsets", zone_id);
let payload = serde_json::json!({
"name": format!("{}.", domain),
"type": "TXT",
"records": [format!("\"{}\"", value)],
"ttl": 300
})
.to_string();
let (timestamp, signature) = self.sign_request("POST", &url, &payload);
let endpoint = format!("https://dns.{}.myhuaweicloud.com{}", self.region, url);
let response = self
.client
.post(&endpoint)
.header("X-Sdk-Date", timestamp)
.header("Content-Type", "application/json")
.header(
"Authorization",
format!(
"SDK-HMAC-SHA256 Access={}, SignedHeaders=host;x-sdk-date, Signature={}",
self.access_key, signature
),
)
.body(payload)
.send()
.await
.map_err(|e| {
tracing::error!(
"Network error during Huawei Cloud DNS record creation: {}",
e
);
AcmeError::transport(format!("Huawei API create failed: {}", e))
})?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
tracing::error!("Huawei Cloud DNS API error: {}", error_text);
return Err(AcmeError::protocol(format!("Huawei error: {}", error_text)));
}
let body: serde_json::Value = response.json().await.map_err(|e| {
tracing::error!("Failed to parse Huawei Cloud DNS creation response: {}", e);
AcmeError::protocol(format!("Failed to parse Huawei response: {}", e))
})?;
let record_id = body["id"].as_str().ok_or_else(|| {
tracing::error!("'id' missing in Huawei Cloud DNS creation response");
AcmeError::protocol("Huawei response missing record ID".to_string())
})?;
tracing::info!(
"Successfully created Huawei Cloud DNS TXT record with ID: {}",
record_id
);
Ok(format!("{}:{}", zone_id, record_id))
}
async fn delete_txt_record(&self, _domain: &str, record_id: &str) -> Result<()> {
tracing::info!(
"Deleting TXT record from Huawei Cloud DNS, ID: {}",
record_id
);
let parts: Vec<&str> = record_id.split(':').collect();
if parts.len() != 2 {
tracing::error!("Invalid Huawei record ID format: {}", record_id);
return Err(AcmeError::invalid_input("Invalid Huawei record ID format"));
}
let zone_id = parts[0];
let real_record_id = parts[1];
let url = format!("/v2/zones/{}/recordsets/{}", zone_id, real_record_id);
let (timestamp, signature) = self.sign_request("DELETE", &url, "");
let endpoint = format!("https://dns.{}.myhuaweicloud.com{}", self.region, url);
let response = self
.client
.delete(&endpoint)
.header("X-Sdk-Date", timestamp)
.header(
"Authorization",
format!(
"SDK-HMAC-SHA256 Access={}, SignedHeaders=host;x-sdk-date, Signature={}",
self.access_key, signature
),
)
.send()
.await
.map_err(|e| {
tracing::error!(
"Network error during Huawei Cloud DNS record deletion: {}",
e
);
AcmeError::transport(format!("Huawei API delete failed: {}", e))
})?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
tracing::error!("Huawei Cloud DNS API deletion failed: {}", error_text);
return Err(AcmeError::protocol(format!(
"Huawei delete error: {}",
error_text
)));
}
tracing::info!("Successfully deleted Huawei Cloud DNS TXT record");
Ok(())
}
async fn verify_record(&self, domain: &str, value: &str) -> Result<bool> {
tracing::debug!(
"Verifying TXT record in Huawei Cloud DNS for domain: {}",
domain
);
let zone_id = match self.find_zone_id(domain).await {
Ok(id) => id,
Err(_) => return Ok(false),
};
let url = format!("/v2/zones/{zone_id}/recordsets?name={domain}&type=TXT");
let (timestamp, signature) = self.sign_request("GET", &url, "");
let endpoint = format!("https://dns.{}.myhuaweicloud.com{url}", self.region);
let response = self.client.get(&endpoint)
.header("X-Sdk-Date", timestamp)
.header("Authorization", format!(
"SDK-HMAC-SHA256 Access={}, SignedHeaders=host;x-sdk-date, Signature={signature}",
self.access_key
))
.send()
.await
.map_err(|e| {
tracing::error!("Network error during Huawei Cloud DNS record verification: {}", e);
AcmeError::transport(format!("Huawei API verify failed: {}", e))
})?;
let body: serde_json::Value = response.json().await.unwrap_or_default();
let quoted_value = format!("\"{}\"", value);
if let Some(recordsets) = body["recordsets"].as_array() {
for rs in recordsets {
if let Some(records) = rs["records"].as_array() {
for r in records {
if r.as_str() == Some("ed_value) {
tracing::debug!("Huawei Cloud DNS record verification successful");
return Ok(true);
}
}
}
}
}
tracing::warn!("Huawei Cloud DNS record verification failed: value not found");
Ok(false)
}
}