1#![cfg(any(feature = "ring", feature = "aws-lc-rs"))]
13
14use crate::crypto::{hmac_sha256, sha256_digest};
15use crate::utils::txt_chunks_to_text;
16use crate::{DnsRecord, DnsRecordType, Error, IntoFqdn};
17use chrono::Utc;
18use reqwest::Client;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::time::Duration;
22
23const VOLCENGINE_DEFAULT_HOST: &str = "open.volcengineapi.com";
24const VOLCENGINE_DEFAULT_REGION: &str = "cn-north-1";
25const VOLCENGINE_SERVICE: &str = "DNS";
26const VOLCENGINE_API_VERSION: &str = "2018-08-01";
27const VOLCENGINE_SIGN_ALGORITHM: &str = "HMAC-SHA256";
28
29#[derive(Debug, Clone)]
30pub struct VolcengineConfig {
31 pub access_key: String,
32 pub secret_key: String,
33 pub region: Option<String>,
34 pub host: Option<String>,
35 pub scheme: Option<String>,
36 pub request_timeout: Option<Duration>,
37}
38
39#[derive(Clone)]
40pub struct VolcengineProvider {
41 client: Client,
42 config: VolcengineConfig,
43 region: String,
44 host: String,
45 scheme: String,
46}
47
48impl VolcengineProvider {
49 pub(crate) fn new(config: VolcengineConfig) -> crate::Result<Self> {
50 if config.access_key.is_empty() || config.secret_key.is_empty() {
51 return Err(Error::Api(
52 "Volcengine credentials are required (access_key and secret_key)".into(),
53 ));
54 }
55
56 let region = config
57 .region
58 .clone()
59 .unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string());
60 let host = config
61 .host
62 .clone()
63 .unwrap_or_else(|| VOLCENGINE_DEFAULT_HOST.to_string());
64 let scheme = config.scheme.clone().unwrap_or_else(|| "https".to_string());
65
66 let mut builder = Client::builder();
67 if let Some(timeout) = config.request_timeout {
68 builder = builder.timeout(timeout);
69 }
70 let client = builder
71 .build()
72 .map_err(|e| Error::Client(format!("Failed to build reqwest client: {}", e)))?;
73
74 Ok(Self {
75 client,
76 config,
77 region,
78 host,
79 scheme,
80 })
81 }
82
83 #[cfg(test)]
84 pub(crate) fn with_endpoint(mut self, endpoint: impl AsRef<str>) -> Self {
85 let endpoint = endpoint.as_ref();
86 if let Some(rest) = endpoint.strip_prefix("https://") {
87 self.scheme = "https".to_string();
88 self.host = rest.trim_end_matches('/').to_string();
89 } else if let Some(rest) = endpoint.strip_prefix("http://") {
90 self.scheme = "http".to_string();
91 self.host = rest.trim_end_matches('/').to_string();
92 } else {
93 self.host = endpoint.trim_end_matches('/').to_string();
94 }
95 self
96 }
97
98 pub(crate) async fn create(
99 &self,
100 name: impl IntoFqdn<'_>,
101 record: DnsRecord,
102 ttl: u32,
103 origin: impl IntoFqdn<'_>,
104 ) -> crate::Result<()> {
105 let name = name.into_name().to_string();
106 let origin = origin.into_name().to_string();
107 let zone = self.get_zone(&origin).await?;
108 let host = subdomain_for(&name, &zone.name);
109 let entry = record_to_entry(&record)?;
110
111 let body = serde_json::json!({
112 "ZID": zone.id,
113 "Host": host,
114 "Type": entry.record_type,
115 "Value": entry.value,
116 "TTL": ttl,
117 });
118
119 let final_body = if let Some(priority) = entry.priority {
120 let mut value = body;
121 value["Weight"] = priority.into();
122 value
123 } else {
124 body
125 };
126
127 self.send_action("CreateRecord", final_body).await.map(|_| ())
128 }
129
130 pub(crate) async fn update(
131 &self,
132 name: impl IntoFqdn<'_>,
133 record: DnsRecord,
134 ttl: u32,
135 origin: impl IntoFqdn<'_>,
136 ) -> crate::Result<()> {
137 let name = name.into_name().to_string();
138 let origin = origin.into_name().to_string();
139 let zone = self.get_zone(&origin).await?;
140 let host = subdomain_for(&name, &zone.name);
141 let entry = record_to_entry(&record)?;
142 let record_id = self
143 .find_record_id(&zone.id, &host, &entry.record_type)
144 .await?;
145
146 let body = serde_json::json!({
147 "RecordID": record_id,
148 "Host": host,
149 "Type": entry.record_type,
150 "Value": entry.value,
151 "TTL": ttl,
152 });
153
154 let final_body = if let Some(priority) = entry.priority {
155 let mut value = body;
156 value["Weight"] = priority.into();
157 value
158 } else {
159 body
160 };
161
162 self.send_action("UpdateRecord", final_body).await.map(|_| ())
163 }
164
165 pub(crate) async fn delete(
166 &self,
167 name: impl IntoFqdn<'_>,
168 origin: impl IntoFqdn<'_>,
169 record_type: DnsRecordType,
170 ) -> crate::Result<()> {
171 let name = name.into_name().to_string();
172 let origin = origin.into_name().to_string();
173 let zone = self.get_zone(&origin).await?;
174 let host = subdomain_for(&name, &zone.name);
175 let type_str = record_type_str(record_type)?;
176 let record_id = self.find_record_id(&zone.id, &host, type_str).await?;
177
178 let body = serde_json::json!({ "RecordID": record_id });
179 self.send_action("DeleteRecord", body).await.map(|_| ())
180 }
181
182 async fn get_zone(&self, origin: &str) -> crate::Result<ResolvedZone> {
183 let trimmed = origin.trim_end_matches('.').to_string();
184 let body = serde_json::json!({
185 "Key": trimmed,
186 "SearchMode": "exact",
187 });
188 let response = self.send_action("ListZones", body).await?;
189 let result = response
190 .get("Result")
191 .ok_or_else(|| Error::Api("Volcengine ListZones response missing Result".into()))?;
192 let total = result
193 .get("Total")
194 .and_then(Value::as_u64)
195 .unwrap_or(0);
196 if total == 0 {
197 return Err(Error::Api(format!(
198 "No Volcengine zone found for origin {}",
199 origin
200 )));
201 }
202 if total > 1 {
203 return Err(Error::Api(format!(
204 "Multiple Volcengine zones matched origin {}",
205 origin
206 )));
207 }
208 let zones = result
209 .get("Zones")
210 .and_then(Value::as_array)
211 .ok_or_else(|| Error::Api("Volcengine ListZones response missing Zones".into()))?;
212 let zone = zones.first().ok_or_else(|| {
213 Error::Api(format!("Volcengine zone list empty for origin {}", origin))
214 })?;
215 let id = zone
216 .get("ZID")
217 .and_then(Value::as_i64)
218 .ok_or_else(|| Error::Api("Volcengine zone missing ZID".into()))?;
219 let name = zone
220 .get("ZoneName")
221 .and_then(Value::as_str)
222 .ok_or_else(|| Error::Api("Volcengine zone missing ZoneName".into()))?
223 .trim_end_matches('.')
224 .to_string();
225 Ok(ResolvedZone { id, name })
226 }
227
228 async fn find_record_id(
229 &self,
230 zone_id: &i64,
231 host: &str,
232 record_type: &str,
233 ) -> crate::Result<String> {
234 let body = serde_json::json!({
235 "ZID": zone_id,
236 "Host": host,
237 "Type": record_type,
238 "PageSize": 100,
239 });
240 let response = self.send_action("ListRecords", body).await?;
241 let result = response
242 .get("Result")
243 .ok_or_else(|| Error::Api("Volcengine ListRecords response missing Result".into()))?;
244 let records = result
245 .get("Records")
246 .and_then(Value::as_array)
247 .ok_or_else(|| Error::Api("Volcengine ListRecords response missing Records".into()))?;
248 let record = records
249 .iter()
250 .find(|r| {
251 let h = r.get("Host").and_then(Value::as_str).unwrap_or("");
252 let t = r.get("Type").and_then(Value::as_str).unwrap_or("");
253 h == host && t == record_type
254 })
255 .ok_or_else(|| {
256 Error::Api(format!(
257 "Volcengine record {} of type {} not found",
258 host, record_type
259 ))
260 })?;
261 record
262 .get("RecordID")
263 .and_then(Value::as_str)
264 .map(ToString::to_string)
265 .ok_or_else(|| Error::Api("Volcengine record missing RecordID".into()))
266 }
267
268 async fn send_action(&self, action: &str, body: Value) -> crate::Result<Value> {
269 let body_text = serde_json::to_string(&body)
270 .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
271 let query = format!("Action={}&Version={}", action, VOLCENGINE_API_VERSION);
272 let canonical_query = canonical_query_string(&query);
273
274 let datetime = Utc::now();
275 let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string();
276 let date_stamp = datetime.format("%Y%m%d").to_string();
277 let payload_hash = hex::encode(sha256_digest(body_text.as_bytes()));
278
279 let canonical_headers = format!(
280 "content-type:application/json\nhost:{}\nx-content-sha256:{}\nx-date:{}\n",
281 self.host, payload_hash, amz_date
282 );
283 let signed_headers = "content-type;host;x-content-sha256;x-date";
284
285 let canonical_request = format!(
286 "POST\n/\n{}\n{}\n{}\n{}",
287 canonical_query, canonical_headers, signed_headers, payload_hash
288 );
289
290 let credential_scope = format!(
291 "{}/{}/{}/request",
292 date_stamp, self.region, VOLCENGINE_SERVICE
293 );
294 let string_to_sign = format!(
295 "{}\n{}\n{}\n{}",
296 VOLCENGINE_SIGN_ALGORITHM,
297 amz_date,
298 credential_scope,
299 hex::encode(sha256_digest(canonical_request.as_bytes()))
300 );
301
302 let signing_key = self.derive_signing_key(&date_stamp);
303 let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));
304
305 let authorization = format!(
306 "{} Credential={}/{}, SignedHeaders={}, Signature={}",
307 VOLCENGINE_SIGN_ALGORITHM,
308 self.config.access_key,
309 credential_scope,
310 signed_headers,
311 signature
312 );
313
314 let url = format!("{}://{}/?{}", self.scheme, self.host, query);
315 let response = self
316 .client
317 .post(&url)
318 .header("Content-Type", "application/json")
319 .header("Host", &self.host)
320 .header("X-Date", &amz_date)
321 .header("X-Content-Sha256", &payload_hash)
322 .header("Authorization", &authorization)
323 .body(body_text)
324 .send()
325 .await
326 .map_err(|e| Error::Api(format!("Volcengine request failed: {}", e)))?;
327
328 let status = response.status();
329 let text = response
330 .text()
331 .await
332 .map_err(|e| Error::Api(format!("Failed to read Volcengine response: {}", e)))?;
333
334 if !status.is_success() {
335 return Err(match status.as_u16() {
336 400 => Error::Api(format!("BadRequest {}", text)),
337 401 | 403 => Error::Unauthorized,
338 404 => Error::NotFound,
339 _ => Error::Api(format!("Volcengine API error {}: {}", status, text)),
340 });
341 }
342
343 let parsed: Value = if text.is_empty() {
344 Value::Null
345 } else {
346 serde_json::from_str(&text)
347 .map_err(|e| Error::Api(format!("Failed to parse Volcengine response: {}", e)))?
348 };
349
350 if let Some(error) = parsed.get("ResponseMetadata").and_then(|m| m.get("Error")) {
351 let code = error
352 .get("CodeN")
353 .and_then(Value::as_i64)
354 .unwrap_or_default();
355 let message = error
356 .get("Message")
357 .and_then(Value::as_str)
358 .unwrap_or("unknown error");
359 return Err(Error::Api(format!(
360 "Volcengine API error {}: {}",
361 code, message
362 )));
363 }
364
365 Ok(parsed)
366 }
367
368 fn derive_signing_key(&self, date_stamp: &str) -> Vec<u8> {
369 let k_date = hmac_sha256(self.config.secret_key.as_bytes(), date_stamp.as_bytes());
370 let k_region = hmac_sha256(&k_date, self.region.as_bytes());
371 let k_service = hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes());
372 hmac_sha256(&k_service, b"request")
373 }
374}
375
376#[derive(Debug, Clone)]
377struct ResolvedZone {
378 id: i64,
379 name: String,
380}
381
382#[derive(Debug, Serialize, Deserialize)]
383struct RecordEntry {
384 record_type: String,
385 value: String,
386 priority: Option<u16>,
387}
388
389fn record_to_entry(record: &DnsRecord) -> crate::Result<RecordEntry> {
390 let entry = match record {
391 DnsRecord::A(ip) => RecordEntry {
392 record_type: "A".into(),
393 value: ip.to_string(),
394 priority: None,
395 },
396 DnsRecord::AAAA(ip) => RecordEntry {
397 record_type: "AAAA".into(),
398 value: ip.to_string(),
399 priority: None,
400 },
401 DnsRecord::CNAME(target) => RecordEntry {
402 record_type: "CNAME".into(),
403 value: target.trim_end_matches('.').to_string(),
404 priority: None,
405 },
406 DnsRecord::NS(target) => RecordEntry {
407 record_type: "NS".into(),
408 value: target.trim_end_matches('.').to_string(),
409 priority: None,
410 },
411 DnsRecord::MX(mx) => RecordEntry {
412 record_type: "MX".into(),
413 value: mx.exchange.trim_end_matches('.').to_string(),
414 priority: Some(mx.priority),
415 },
416 DnsRecord::TXT(txt) => {
417 let mut buf = String::new();
418 txt_chunks_to_text(&mut buf, txt, " ");
419 RecordEntry {
420 record_type: "TXT".into(),
421 value: buf,
422 priority: None,
423 }
424 }
425 DnsRecord::SRV(srv) => RecordEntry {
426 record_type: "SRV".into(),
427 value: format!(
428 "{} {} {} {}",
429 srv.priority,
430 srv.weight,
431 srv.port,
432 srv.target.trim_end_matches('.')
433 ),
434 priority: None,
435 },
436 DnsRecord::CAA(caa) => {
437 let (flags, tag, value) = caa.clone().decompose();
438 RecordEntry {
439 record_type: "CAA".into(),
440 value: format!("{} {} \"{}\"", flags, tag, value),
441 priority: None,
442 }
443 }
444 DnsRecord::TLSA(_) => {
445 return Err(Error::Api(
446 "TLSA records are not supported by Volcengine".into(),
447 ));
448 }
449 };
450 Ok(entry)
451}
452
453fn record_type_str(record_type: DnsRecordType) -> crate::Result<&'static str> {
454 Ok(match record_type {
455 DnsRecordType::A => "A",
456 DnsRecordType::AAAA => "AAAA",
457 DnsRecordType::CNAME => "CNAME",
458 DnsRecordType::NS => "NS",
459 DnsRecordType::MX => "MX",
460 DnsRecordType::TXT => "TXT",
461 DnsRecordType::SRV => "SRV",
462 DnsRecordType::CAA => "CAA",
463 DnsRecordType::TLSA => {
464 return Err(Error::Api(
465 "TLSA records are not supported by Volcengine".into(),
466 ));
467 }
468 })
469}
470
471fn subdomain_for(name: &str, zone_name: &str) -> String {
472 let name = name.trim_end_matches('.');
473 let zone = zone_name.trim_end_matches('.');
474 if name == zone {
475 "@".to_string()
476 } else if let Some(stripped) = name.strip_suffix(&format!(".{}", zone)) {
477 stripped.to_string()
478 } else {
479 name.to_string()
480 }
481}
482
483fn canonical_query_string(query: &str) -> String {
484 let mut pairs: Vec<(String, String)> = query
485 .split('&')
486 .filter(|s| !s.is_empty())
487 .map(|p| {
488 let mut iter = p.splitn(2, '=');
489 let k = iter.next().unwrap_or("");
490 let v = iter.next().unwrap_or("");
491 (
492 volc_uri_encode(k, true),
493 volc_uri_encode(v, true),
494 )
495 })
496 .collect();
497 pairs.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
498 pairs
499 .into_iter()
500 .map(|(k, v)| format!("{}={}", k, v))
501 .collect::<Vec<_>>()
502 .join("&")
503}
504
505fn volc_uri_encode(input: &str, encode_slash: bool) -> String {
506 let mut out = String::with_capacity(input.len());
507 for &b in input.as_bytes() {
508 let ch = b as char;
509 let unreserved = ch.is_ascii_alphanumeric()
510 || ch == '-'
511 || ch == '_'
512 || ch == '.'
513 || ch == '~'
514 || (!encode_slash && ch == '/');
515 if unreserved {
516 out.push(ch);
517 } else {
518 out.push_str(&format!("%{:02X}", b));
519 }
520 }
521 out
522}