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