1#![cfg(any(feature = "ring", feature = "aws-lc-rs"))]
13
14use crate::crypto::{hmac_sha256, sha256_digest};
15use crate::http::{HttpClient, HttpClientBuilder};
16use crate::utils::{strip_origin_from_name, txt_chunks_to_text};
17use crate::{CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord};
18use chrono::Utc;
19use serde::Deserialize;
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 access_key: String,
42 secret_key: String,
43 region: String,
44 host: String,
45 scheme: String,
46 client: HttpClient,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50struct ListedRecord {
51 #[serde(rename = "RecordID")]
52 record_id: String,
53 #[serde(rename = "Host")]
54 host: String,
55 #[serde(rename = "Type")]
56 record_type: String,
57 #[serde(rename = "Value", default)]
58 value: String,
59}
60
61#[derive(Debug, Clone)]
62struct ResolvedZone {
63 id: i64,
64 name: String,
65}
66
67impl VolcengineProvider {
68 pub(crate) fn new(config: VolcengineConfig) -> crate::Result<Self> {
69 if config.access_key.is_empty() || config.secret_key.is_empty() {
70 return Err(Error::Api(
71 "Volcengine credentials are required (access_key and secret_key)".into(),
72 ));
73 }
74
75 let region = config
76 .region
77 .unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string());
78 let host = config
79 .host
80 .unwrap_or_else(|| VOLCENGINE_DEFAULT_HOST.to_string());
81 let scheme = config.scheme.unwrap_or_else(|| "https".to_string());
82
83 let client = HttpClientBuilder::default()
84 .with_timeout(config.request_timeout)
85 .build();
86 Ok(Self {
87 access_key: config.access_key,
88 secret_key: config.secret_key,
89 region,
90 host,
91 scheme,
92 client,
93 })
94 }
95
96 #[cfg(test)]
97 pub(crate) fn with_endpoint(mut self, endpoint: impl AsRef<str>) -> Self {
98 let endpoint = endpoint.as_ref();
99 if let Some(rest) = endpoint.strip_prefix("https://") {
100 self.scheme = "https".to_string();
101 self.host = rest.trim_end_matches('/').to_string();
102 } else if let Some(rest) = endpoint.strip_prefix("http://") {
103 self.scheme = "http".to_string();
104 self.host = rest.trim_end_matches('/').to_string();
105 } else {
106 self.host = endpoint.trim_end_matches('/').to_string();
107 }
108 self
109 }
110
111 pub(crate) async fn set_rrset(
112 &self,
113 name: impl IntoFqdn<'_>,
114 record_type: DnsRecordType,
115 ttl: u32,
116 records: Vec<DnsRecord>,
117 origin: impl IntoFqdn<'_>,
118 ) -> crate::Result<()> {
119 let type_str = record_type_str(record_type)?;
120 let desired = build_values(record_type, records)?;
121 let name = name.into_name().to_string();
122 let origin = origin.into_name().to_string();
123 let zone = self.get_zone(&origin).await?;
124 let host = strip_origin_from_name(&name, &zone.name, None);
125 let existing = self.list_records(zone.id, &host, type_str).await?;
126
127 let mut pool = existing;
128 let mut to_add: Vec<String> = Vec::new();
129 for value in desired {
130 if let Some(idx) = pool.iter().position(|r| r.value == value) {
131 pool.swap_remove(idx);
132 } else if !to_add.contains(&value) {
133 to_add.push(value);
134 }
135 }
136
137 for stale in pool {
138 self.delete_record(&stale.record_id).await?;
139 }
140 for value in to_add {
141 self.create_record(zone.id, &host, type_str, &value, ttl)
142 .await?;
143 }
144 Ok(())
145 }
146
147 pub(crate) async fn add_to_rrset(
148 &self,
149 name: impl IntoFqdn<'_>,
150 record_type: DnsRecordType,
151 ttl: u32,
152 records: Vec<DnsRecord>,
153 origin: impl IntoFqdn<'_>,
154 ) -> crate::Result<()> {
155 if records.is_empty() {
156 return Ok(());
157 }
158 let type_str = record_type_str(record_type)?;
159 let desired = build_values(record_type, records)?;
160 let name = name.into_name().to_string();
161 let origin = origin.into_name().to_string();
162 let zone = self.get_zone(&origin).await?;
163 let host = strip_origin_from_name(&name, &zone.name, None);
164 let existing = self.list_records(zone.id, &host, type_str).await?;
165
166 let mut queued: Vec<String> = Vec::new();
167 for value in desired {
168 if existing.iter().any(|r| r.value == value) {
169 continue;
170 }
171 if queued.contains(&value) {
172 continue;
173 }
174 self.create_record(zone.id, &host, type_str, &value, ttl)
175 .await?;
176 queued.push(value);
177 }
178 Ok(())
179 }
180
181 pub(crate) async fn remove_from_rrset(
182 &self,
183 name: impl IntoFqdn<'_>,
184 record_type: DnsRecordType,
185 records: Vec<DnsRecord>,
186 origin: impl IntoFqdn<'_>,
187 ) -> crate::Result<()> {
188 if records.is_empty() {
189 return Ok(());
190 }
191 let type_str = record_type_str(record_type)?;
192 let to_remove = build_values(record_type, records)?;
193 let name = name.into_name().to_string();
194 let origin = origin.into_name().to_string();
195 let zone = self.get_zone(&origin).await?;
196 let host = strip_origin_from_name(&name, &zone.name, None);
197 let existing = self.list_records(zone.id, &host, type_str).await?;
198
199 for value in to_remove {
200 if let Some(entry) = existing.iter().find(|r| r.value == value) {
201 self.delete_record(&entry.record_id).await?;
202 }
203 }
204 Ok(())
205 }
206
207 pub(crate) async fn list_rrset(
208 &self,
209 name: impl IntoFqdn<'_>,
210 record_type: DnsRecordType,
211 origin: impl IntoFqdn<'_>,
212 ) -> crate::Result<Vec<DnsRecord>> {
213 let type_str = record_type_str(record_type)?;
214 let name = name.into_name().to_string();
215 let origin = origin.into_name().to_string();
216 let zone = self.get_zone(&origin).await?;
217 let host = strip_origin_from_name(&name, &zone.name, None);
218 let existing = self.list_records(zone.id, &host, type_str).await?;
219 existing
220 .into_iter()
221 .map(|r| value_to_record(record_type, &r.value))
222 .collect()
223 }
224
225 async fn get_zone(&self, origin: &str) -> crate::Result<ResolvedZone> {
226 let trimmed = origin.trim_end_matches('.').to_string();
227 let body = serde_json::json!({
228 "Key": trimmed,
229 "SearchMode": "exact",
230 });
231 let response = self.send_action("ListZones", body).await?;
232 let result = response
233 .get("Result")
234 .ok_or_else(|| Error::Api("Volcengine ListZones response missing Result".into()))?;
235 let zones = result
236 .get("Zones")
237 .and_then(Value::as_array)
238 .ok_or_else(|| Error::Api("Volcengine ListZones response missing Zones".into()))?;
239 let matched = zones
240 .iter()
241 .find(|z| {
242 z.get("ZoneName")
243 .and_then(Value::as_str)
244 .map(|n| n.trim_end_matches('.') == trimmed)
245 .unwrap_or(false)
246 })
247 .ok_or_else(|| Error::Api(format!("No Volcengine zone found for origin {}", origin)))?;
248 let id = matched
249 .get("ZID")
250 .and_then(Value::as_i64)
251 .ok_or_else(|| Error::Api("Volcengine zone missing ZID".into()))?;
252 let name = matched
253 .get("ZoneName")
254 .and_then(Value::as_str)
255 .ok_or_else(|| Error::Api("Volcengine zone missing ZoneName".into()))?
256 .trim_end_matches('.')
257 .to_string();
258 Ok(ResolvedZone { id, name })
259 }
260
261 async fn list_records(
262 &self,
263 zone_id: i64,
264 host: &str,
265 record_type: &str,
266 ) -> crate::Result<Vec<ListedRecord>> {
267 let body = serde_json::json!({
268 "ZID": zone_id,
269 "Host": host,
270 "Type": record_type,
271 "SearchMode": "exact",
272 "PageSize": "100",
273 });
274 let response = self.send_action("ListRecords", body).await?;
275 let records = response
276 .get("Result")
277 .and_then(|r| r.get("Records"))
278 .cloned()
279 .unwrap_or(Value::Array(Vec::new()));
280 let parsed: Vec<ListedRecord> = serde_json::from_value(records).map_err(|e| {
281 Error::Serialize(format!("Failed to parse Volcengine record list: {}", e))
282 })?;
283 Ok(parsed
284 .into_iter()
285 .filter(|r| r.host == host && r.record_type == record_type)
286 .collect())
287 }
288
289 async fn create_record(
290 &self,
291 zone_id: i64,
292 host: &str,
293 record_type: &str,
294 value: &str,
295 ttl: u32,
296 ) -> crate::Result<()> {
297 let body = serde_json::json!({
298 "ZID": zone_id,
299 "Host": host,
300 "Type": record_type,
301 "Value": value,
302 "TTL": ttl,
303 });
304 self.send_action("CreateRecord", body).await.map(|_| ())
305 }
306
307 async fn delete_record(&self, record_id: &str) -> crate::Result<()> {
308 let body = serde_json::json!({ "RecordID": record_id });
309 self.send_action("DeleteRecord", body).await.map(|_| ())
310 }
311
312 async fn send_action(&self, action: &str, body: Value) -> crate::Result<Value> {
313 let body_text = serde_json::to_string(&body)
314 .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
315 let query = format!("Action={}&Version={}", action, VOLCENGINE_API_VERSION);
316 let canonical_query = canonical_query_string(&query);
317
318 let datetime = Utc::now();
319 let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string();
320 let date_stamp = datetime.format("%Y%m%d").to_string();
321 let payload_hash = hex::encode(sha256_digest(body_text.as_bytes()));
322
323 let canonical_headers = format!(
324 "content-type:application/json\nhost:{}\nx-content-sha256:{}\nx-date:{}\n",
325 self.host, payload_hash, amz_date
326 );
327 let signed_headers = "content-type;host;x-content-sha256;x-date";
328
329 let canonical_request = format!(
330 "POST\n/\n{}\n{}\n{}\n{}",
331 canonical_query, canonical_headers, signed_headers, payload_hash
332 );
333
334 let credential_scope = format!(
335 "{}/{}/{}/request",
336 date_stamp, self.region, VOLCENGINE_SERVICE
337 );
338 let string_to_sign = format!(
339 "{}\n{}\n{}\n{}",
340 VOLCENGINE_SIGN_ALGORITHM,
341 amz_date,
342 credential_scope,
343 hex::encode(sha256_digest(canonical_request.as_bytes()))
344 );
345
346 let signing_key = self.derive_signing_key(&date_stamp);
347 let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));
348
349 let authorization = format!(
350 "{} Credential={}/{}, SignedHeaders={}, Signature={}",
351 VOLCENGINE_SIGN_ALGORITHM, self.access_key, credential_scope, signed_headers, signature
352 );
353
354 let url = format!("{}://{}/?{}", self.scheme, self.host, query);
355 let text = self
356 .client
357 .post(url)
358 .with_header("Host", &self.host)
359 .with_header("X-Date", &amz_date)
360 .with_header("X-Content-Sha256", &payload_hash)
361 .with_header("Authorization", &authorization)
362 .with_raw_body(body_text)
363 .send_raw()
364 .await?;
365
366 let parsed: Value = if text.is_empty() {
367 Value::Null
368 } else {
369 serde_json::from_str(&text)
370 .map_err(|e| Error::Api(format!("Failed to parse Volcengine response: {}", e)))?
371 };
372
373 if let Some(error) = parsed.get("ResponseMetadata").and_then(|m| m.get("Error")) {
374 let code = error
375 .get("CodeN")
376 .and_then(Value::as_i64)
377 .unwrap_or_default();
378 let message = error
379 .get("Message")
380 .and_then(Value::as_str)
381 .unwrap_or("unknown error");
382 return Err(Error::Api(format!(
383 "Volcengine API error {}: {}",
384 code, message
385 )));
386 }
387
388 Ok(parsed)
389 }
390
391 fn derive_signing_key(&self, date_stamp: &str) -> Vec<u8> {
392 let k_date = hmac_sha256(self.secret_key.as_bytes(), date_stamp.as_bytes());
393 let k_region = hmac_sha256(&k_date, self.region.as_bytes());
394 let k_service = hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes());
395 hmac_sha256(&k_service, b"request")
396 }
397}
398
399fn record_to_value(record: &DnsRecord) -> crate::Result<(&'static str, String)> {
400 Ok(match record {
401 DnsRecord::A(ip) => ("A", ip.to_string()),
402 DnsRecord::AAAA(ip) => ("AAAA", ip.to_string()),
403 DnsRecord::CNAME(target) => ("CNAME", target.trim_end_matches('.').to_string()),
404 DnsRecord::NS(target) => ("NS", target.trim_end_matches('.').to_string()),
405 DnsRecord::MX(mx) => (
406 "MX",
407 format!("{} {}", mx.priority, mx.exchange.trim_end_matches('.')),
408 ),
409 DnsRecord::TXT(txt) => {
410 let mut buf = String::new();
411 txt_chunks_to_text(&mut buf, txt, " ");
412 ("TXT", buf)
413 }
414 DnsRecord::SRV(srv) => (
415 "SRV",
416 format!(
417 "{} {} {} {}",
418 srv.priority,
419 srv.weight,
420 srv.port,
421 srv.target.trim_end_matches('.')
422 ),
423 ),
424 DnsRecord::CAA(caa) => {
425 let (flags, tag, value) = caa.clone().decompose();
426 ("CAA", format!("{} {} \"{}\"", flags, tag, value))
427 }
428 DnsRecord::TLSA(_) => {
429 return Err(Error::Unsupported(
430 "TLSA records are not supported by Volcengine".into(),
431 ));
432 }
433 })
434}
435
436fn build_values(
437 expected_type: DnsRecordType,
438 records: Vec<DnsRecord>,
439) -> crate::Result<Vec<String>> {
440 let mut out = Vec::with_capacity(records.len());
441 for record in records {
442 if record.as_type() != expected_type {
443 return Err(Error::Api(format!(
444 "RRSet record type mismatch: expected {}, got {}",
445 expected_type.as_str(),
446 record.as_type().as_str(),
447 )));
448 }
449 let (_, value) = record_to_value(&record)?;
450 out.push(value);
451 }
452 Ok(out)
453}
454
455fn value_to_record(record_type: DnsRecordType, value: &str) -> crate::Result<DnsRecord> {
456 match record_type {
457 DnsRecordType::A => value
458 .parse()
459 .map(DnsRecord::A)
460 .map_err(|e| Error::Parse(format!("Invalid A value {}: {}", value, e))),
461 DnsRecordType::AAAA => value
462 .parse()
463 .map(DnsRecord::AAAA)
464 .map_err(|e| Error::Parse(format!("Invalid AAAA value {}: {}", value, e))),
465 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(value.to_string())),
466 DnsRecordType::NS => Ok(DnsRecord::NS(value.to_string())),
467 DnsRecordType::MX => {
468 let (priority, exchange) = value
469 .split_once(' ')
470 .ok_or_else(|| Error::Parse(format!("Invalid MX value (no space): {}", value)))?;
471 let priority: u16 = priority
472 .parse()
473 .map_err(|e| Error::Parse(format!("Invalid MX priority {}: {}", priority, e)))?;
474 Ok(DnsRecord::MX(MXRecord {
475 priority,
476 exchange: exchange.to_string(),
477 }))
478 }
479 DnsRecordType::TXT => Ok(DnsRecord::TXT(unquote_txt(value))),
480 DnsRecordType::SRV => {
481 let parts: Vec<&str> = value.splitn(4, ' ').collect();
482 if parts.len() != 4 {
483 return Err(Error::Parse(format!("Invalid SRV value: {}", value)));
484 }
485 Ok(DnsRecord::SRV(SRVRecord {
486 priority: parts[0]
487 .parse()
488 .map_err(|e| Error::Parse(format!("Invalid SRV priority: {}", e)))?,
489 weight: parts[1]
490 .parse()
491 .map_err(|e| Error::Parse(format!("Invalid SRV weight: {}", e)))?,
492 port: parts[2]
493 .parse()
494 .map_err(|e| Error::Parse(format!("Invalid SRV port: {}", e)))?,
495 target: parts[3].to_string(),
496 }))
497 }
498 DnsRecordType::CAA => parse_caa_value(value).map(DnsRecord::CAA),
499 DnsRecordType::TLSA => Err(Error::Unsupported(
500 "TLSA records are not supported by Volcengine".into(),
501 )),
502 }
503}
504
505fn unquote_txt(content: &str) -> String {
506 let mut out = String::with_capacity(content.len());
507 let bytes = content.as_bytes();
508 let mut i = 0;
509 let mut in_quotes = false;
510 let mut any_quotes = false;
511 while i < bytes.len() {
512 let b = bytes[i];
513 if b == b'"' {
514 any_quotes = true;
515 in_quotes = !in_quotes;
516 i += 1;
517 continue;
518 }
519 if in_quotes && b == b'\\' && i + 1 < bytes.len() {
520 let next = bytes[i + 1];
521 if next == b'"' || next == b'\\' {
522 out.push(next as char);
523 i += 2;
524 continue;
525 }
526 }
527 if !any_quotes || in_quotes {
528 out.push(b as char);
529 }
530 i += 1;
531 }
532 if !any_quotes {
533 return content.to_string();
534 }
535 out
536}
537
538fn parse_caa_value(value: &str) -> crate::Result<CAARecord> {
539 let trimmed = value.trim();
540 let (flags_str, rest) = trimmed
541 .split_once(' ')
542 .ok_or_else(|| Error::Parse(format!("Invalid CAA value: {}", value)))?;
543 let flags: u8 = flags_str
544 .parse()
545 .map_err(|e| Error::Parse(format!("Invalid CAA flags {}: {}", flags_str, e)))?;
546 let issuer_critical = flags & 0x80 != 0;
547 let (tag, raw_value) = rest
548 .trim_start()
549 .split_once(' ')
550 .ok_or_else(|| Error::Parse(format!("Invalid CAA tag/value: {}", value)))?;
551 let raw_value = raw_value.trim();
552 let stripped = raw_value
553 .strip_prefix('"')
554 .and_then(|s| s.strip_suffix('"'))
555 .unwrap_or(raw_value);
556 match tag {
557 "issue" => {
558 let (name, options) = split_caa_options(stripped);
559 Ok(CAARecord::Issue {
560 issuer_critical,
561 name,
562 options,
563 })
564 }
565 "issuewild" => {
566 let (name, options) = split_caa_options(stripped);
567 Ok(CAARecord::IssueWild {
568 issuer_critical,
569 name,
570 options,
571 })
572 }
573 "iodef" => Ok(CAARecord::Iodef {
574 issuer_critical,
575 url: stripped.to_string(),
576 }),
577 other => Err(Error::Parse(format!("Unknown CAA tag: {}", other))),
578 }
579}
580
581fn split_caa_options(value: &str) -> (Option<String>, Vec<KeyValue>) {
582 let mut parts = value.split(';').map(str::trim);
583 let name_part = parts.next().unwrap_or("").trim().to_string();
584 let name = if name_part.is_empty() {
585 None
586 } else {
587 Some(name_part)
588 };
589 let options = parts
590 .filter(|p| !p.is_empty())
591 .map(|p| match p.split_once('=') {
592 Some((k, v)) => KeyValue {
593 key: k.trim().to_string(),
594 value: v.trim().to_string(),
595 },
596 None => KeyValue {
597 key: p.trim().to_string(),
598 value: String::new(),
599 },
600 })
601 .collect();
602 (name, options)
603}
604
605fn record_type_str(record_type: DnsRecordType) -> crate::Result<&'static str> {
606 Ok(match record_type {
607 DnsRecordType::A => "A",
608 DnsRecordType::AAAA => "AAAA",
609 DnsRecordType::CNAME => "CNAME",
610 DnsRecordType::NS => "NS",
611 DnsRecordType::MX => "MX",
612 DnsRecordType::TXT => "TXT",
613 DnsRecordType::SRV => "SRV",
614 DnsRecordType::CAA => "CAA",
615 DnsRecordType::TLSA => {
616 return Err(Error::Unsupported(
617 "TLSA records are not supported by Volcengine".into(),
618 ));
619 }
620 })
621}
622
623fn canonical_query_string(query: &str) -> String {
624 let mut pairs: Vec<(String, String)> = query
625 .split('&')
626 .filter(|s| !s.is_empty())
627 .map(|p| {
628 let mut iter = p.splitn(2, '=');
629 let k = iter.next().unwrap_or("");
630 let v = iter.next().unwrap_or("");
631 (volc_uri_encode(k, true), volc_uri_encode(v, true))
632 })
633 .collect();
634 pairs.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
635 pairs
636 .into_iter()
637 .map(|(k, v)| format!("{}={}", k, v))
638 .collect::<Vec<_>>()
639 .join("&")
640}
641
642fn volc_uri_encode(input: &str, encode_slash: bool) -> String {
643 let mut out = String::with_capacity(input.len());
644 for &b in input.as_bytes() {
645 let ch = b as char;
646 let unreserved = ch.is_ascii_alphanumeric()
647 || ch == '-'
648 || ch == '_'
649 || ch == '.'
650 || ch == '~'
651 || (!encode_slash && ch == '/');
652 if unreserved {
653 out.push(ch);
654 } else {
655 out.push_str(&format!("%{:02X}", b));
656 }
657 }
658 out
659}