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