1use crate::{
13 DnsRecord, DnsRecordType, Error, IntoFqdn,
14 http::{HttpClient, HttpClientBuilder},
15 utils::strip_origin_from_name,
16};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::time::Duration;
20
21const DEFAULT_API_ENDPOINT: &str = "https://napi.arvancloud.ir";
22const PAGE_SIZE: u32 = 300;
23
24#[derive(Clone)]
25pub struct ArvanCloudProvider {
26 client: HttpClient,
27 endpoint: String,
28}
29
30#[derive(Serialize, Debug, Clone)]
31pub struct ArvanRecordPayload {
32 #[serde(rename = "type")]
33 pub record_type: &'static str,
34 pub name: String,
35 pub value: Value,
36 pub ttl: u32,
37 pub upstream_https: &'static str,
38 pub ip_filter_mode: ArvanIpFilterMode,
39}
40
41#[derive(Serialize, Debug, Clone)]
42pub struct ArvanIpFilterMode {
43 pub count: &'static str,
44 pub order: &'static str,
45 pub geo_filter: &'static str,
46}
47
48impl Default for ArvanIpFilterMode {
49 fn default() -> Self {
50 Self {
51 count: "single",
52 order: "none",
53 geo_filter: "none",
54 }
55 }
56}
57
58#[derive(Deserialize, Debug)]
59pub struct ArvanApiResponse<T> {
60 pub data: T,
61}
62
63#[derive(Deserialize, Debug, Clone)]
64pub struct ArvanListedRecord {
65 pub id: String,
66 pub name: String,
67 #[serde(rename = "type")]
68 pub record_type: String,
69 pub value: Value,
70 #[serde(default = "default_true")]
71 pub can_delete: bool,
72}
73
74fn default_true() -> bool {
75 true
76}
77
78#[derive(Deserialize, Debug)]
79struct ArvanPagedResponse {
80 data: Vec<ArvanListedRecord>,
81 #[serde(default)]
82 meta: Option<ArvanMeta>,
83}
84
85#[derive(Deserialize, Debug)]
86struct ArvanMeta {
87 #[serde(default)]
88 last_page: u32,
89}
90
91pub struct ArvanRecordContent {
92 pub record_type: &'static str,
93 pub value: Value,
94}
95
96#[derive(Debug, Clone)]
97struct DesiredRecord {
98 record_type: &'static str,
99 wire_value: Value,
100 normalized: Value,
101}
102
103impl PartialEq<Value> for DesiredRecord {
104 fn eq(&self, other: &Value) -> bool {
105 self.normalized == *other
106 }
107}
108
109impl ArvanCloudProvider {
110 pub(crate) fn new(api_key: impl AsRef<str>, timeout: Option<Duration>) -> Self {
111 let client = HttpClientBuilder::default()
112 .with_header("Authorization", api_key.as_ref())
113 .with_timeout(timeout)
114 .build();
115 Self {
116 client,
117 endpoint: DEFAULT_API_ENDPOINT.to_string(),
118 }
119 }
120
121 #[cfg(test)]
122 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
123 Self {
124 endpoint: endpoint.as_ref().to_string(),
125 ..self
126 }
127 }
128
129 pub(crate) async fn set_rrset(
130 &self,
131 name: impl IntoFqdn<'_>,
132 record_type: DnsRecordType,
133 ttl: u32,
134 records: Vec<DnsRecord>,
135 origin: impl IntoFqdn<'_>,
136 ) -> crate::Result<()> {
137 check_record_types(record_type, &records)?;
138 let fqdn = name.into_name();
139 let domain = origin.into_name();
140 let subdomain = strip_origin_from_name(&fqdn, &domain, Some("@"));
141 let wire_type = record_type_to_wire(record_type);
142
143 let desired = build_desired(record_type, records)?;
144 let existing = self.list_at(&domain, &subdomain, wire_type).await?;
145
146 let mut existing_pool: Vec<ArvanListedRecord> =
147 existing.into_iter().filter(|r| r.can_delete).collect();
148
149 let mut to_create: Vec<DesiredRecord> = Vec::new();
150 for desired_record in desired {
151 if let Some(idx) = existing_pool
152 .iter()
153 .position(|r| desired_record == normalize_listed(r))
154 {
155 existing_pool.swap_remove(idx);
156 } else {
157 to_create.push(desired_record);
158 }
159 }
160
161 for stale in existing_pool {
162 self.delete_record(&domain, &stale.id).await?;
163 }
164 for desired_record in to_create {
165 self.create_record(&domain, &subdomain, ttl, desired_record)
166 .await?;
167 }
168 Ok(())
169 }
170
171 pub(crate) async fn add_to_rrset(
172 &self,
173 name: impl IntoFqdn<'_>,
174 record_type: DnsRecordType,
175 ttl: u32,
176 records: Vec<DnsRecord>,
177 origin: impl IntoFqdn<'_>,
178 ) -> crate::Result<()> {
179 if records.is_empty() {
180 return Ok(());
181 }
182 check_record_types(record_type, &records)?;
183 let fqdn = name.into_name();
184 let domain = origin.into_name();
185 let subdomain = strip_origin_from_name(&fqdn, &domain, Some("@"));
186 let wire_type = record_type_to_wire(record_type);
187
188 let desired = build_desired(record_type, records)?;
189 let existing = self.list_at(&domain, &subdomain, wire_type).await?;
190
191 for desired_record in desired {
192 if existing
193 .iter()
194 .any(|r| desired_record == normalize_listed(r))
195 {
196 continue;
197 }
198 self.create_record(&domain, &subdomain, ttl, desired_record)
199 .await?;
200 }
201 Ok(())
202 }
203
204 pub(crate) async fn remove_from_rrset(
205 &self,
206 name: impl IntoFqdn<'_>,
207 record_type: DnsRecordType,
208 records: Vec<DnsRecord>,
209 origin: impl IntoFqdn<'_>,
210 ) -> crate::Result<()> {
211 if records.is_empty() {
212 return Ok(());
213 }
214 check_record_types(record_type, &records)?;
215 let fqdn = name.into_name();
216 let domain = origin.into_name();
217 let subdomain = strip_origin_from_name(&fqdn, &domain, Some("@"));
218 let wire_type = record_type_to_wire(record_type);
219
220 let to_remove = build_desired(record_type, records)?;
221 let existing = self.list_at(&domain, &subdomain, wire_type).await?;
222
223 for desired_record in to_remove {
224 if let Some(entry) = existing
225 .iter()
226 .find(|r| desired_record == normalize_listed(r))
227 {
228 if !entry.can_delete {
229 return Err(Error::Api(format!(
230 "ArvanCloud record {} cannot be removed (can_delete=false)",
231 entry.id
232 )));
233 }
234 self.delete_record(&domain, &entry.id).await?;
235 }
236 }
237 Ok(())
238 }
239
240 pub(crate) async fn list_rrset(
241 &self,
242 name: impl IntoFqdn<'_>,
243 record_type: DnsRecordType,
244 origin: impl IntoFqdn<'_>,
245 ) -> crate::Result<Vec<DnsRecord>> {
246 let fqdn = name.into_name();
247 let domain = origin.into_name();
248 let subdomain = strip_origin_from_name(&fqdn, &domain, Some("@"));
249 let wire_type = record_type_to_wire(record_type);
250
251 let existing = self.list_at(&domain, &subdomain, wire_type).await?;
252
253 let mut out = Vec::with_capacity(existing.len());
254 for listed in existing {
255 if let Some(record) = listed_to_dns_record(record_type, &listed.value) {
256 out.push(record);
257 }
258 }
259 Ok(out)
260 }
261
262 async fn list_at(
263 &self,
264 domain: &str,
265 subdomain: &str,
266 wire_type: &str,
267 ) -> crate::Result<Vec<ArvanListedRecord>> {
268 let mut out = Vec::new();
269 let mut page: u32 = 1;
270 loop {
271 let url = format!(
272 "{endpoint}/cdn/4.0/domains/{domain}/dns-records?page={page}&per_page={per_page}",
273 endpoint = self.endpoint,
274 per_page = PAGE_SIZE,
275 );
276 let response: ArvanPagedResponse = self.client.get(url).send().await?;
277 for record in response.data {
278 if record.name == subdomain && record.record_type == wire_type {
279 out.push(record);
280 }
281 }
282 match response.meta {
283 Some(meta) if meta.last_page > 0 && page < meta.last_page => {
284 page += 1;
285 }
286 _ => return Ok(out),
287 }
288 }
289 }
290
291 async fn create_record(
292 &self,
293 domain: &str,
294 subdomain: &str,
295 ttl: u32,
296 desired: DesiredRecord,
297 ) -> crate::Result<()> {
298 let body = ArvanRecordPayload {
299 record_type: desired.record_type,
300 name: subdomain.to_string(),
301 value: desired.wire_value,
302 ttl,
303 upstream_https: "default",
304 ip_filter_mode: ArvanIpFilterMode::default(),
305 };
306 self.client
307 .post(format!(
308 "{endpoint}/cdn/4.0/domains/{domain}/dns-records",
309 endpoint = self.endpoint
310 ))
311 .with_body(&body)?
312 .send_raw()
313 .await
314 .map(|_| ())
315 }
316
317 async fn delete_record(&self, domain: &str, record_id: &str) -> crate::Result<()> {
318 self.client
319 .delete(format!(
320 "{endpoint}/cdn/4.0/domains/{domain}/dns-records/{record_id}",
321 endpoint = self.endpoint
322 ))
323 .send_raw()
324 .await
325 .map(|_| ())
326 }
327}
328
329fn record_type_to_wire(record_type: DnsRecordType) -> &'static str {
330 match record_type {
331 DnsRecordType::A => "a",
332 DnsRecordType::AAAA => "aaaa",
333 DnsRecordType::CNAME => "cname",
334 DnsRecordType::NS => "ns",
335 DnsRecordType::MX => "mx",
336 DnsRecordType::TXT => "txt",
337 DnsRecordType::SRV => "srv",
338 DnsRecordType::TLSA => "tlsa",
339 DnsRecordType::CAA => "caa",
340 }
341}
342
343fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
344 for record in records {
345 if record.as_type() != expected {
346 return Err(Error::Api(format!(
347 "RRSet record type mismatch: expected {}, got {}",
348 expected.as_str(),
349 record.as_type().as_str(),
350 )));
351 }
352 }
353 Ok(())
354}
355
356fn build_desired(
357 expected: DnsRecordType,
358 records: Vec<DnsRecord>,
359) -> crate::Result<Vec<DesiredRecord>> {
360 let mut out = Vec::with_capacity(records.len());
361 for record in records {
362 if record.as_type() != expected {
363 return Err(Error::Api(format!(
364 "RRSet record type mismatch: expected {}, got {}",
365 expected.as_str(),
366 record.as_type().as_str(),
367 )));
368 }
369 let content = ArvanRecordContent::try_from(record)?;
370 let normalized = normalize_value(content.record_type, content.value.clone());
371 out.push(DesiredRecord {
372 record_type: content.record_type,
373 wire_value: content.value,
374 normalized,
375 });
376 }
377 Ok(out)
378}
379
380fn normalize_listed(record: &ArvanListedRecord) -> Value {
381 normalize_value(static_wire(&record.record_type), record.value.clone())
382}
383
384fn static_wire(s: &str) -> &'static str {
385 match s {
386 "a" => "a",
387 "aaaa" => "aaaa",
388 "cname" => "cname",
389 "ns" => "ns",
390 "mx" => "mx",
391 "txt" => "txt",
392 "srv" => "srv",
393 "tlsa" => "tlsa",
394 "caa" => "caa",
395 _ => "",
396 }
397}
398
399fn normalize_value(wire_type: &str, mut value: Value) -> Value {
400 match wire_type {
401 "a" | "aaaa" => {
402 if let Value::Array(items) = &mut value {
403 let mut normalized: Vec<Value> = items
404 .iter_mut()
405 .map(|item| {
406 let ip = item
407 .get("ip")
408 .and_then(|v| v.as_str())
409 .unwrap_or("")
410 .to_string();
411 serde_json::json!({ "ip": ip })
412 })
413 .collect();
414 normalized.sort_by(|a, b| {
415 a.get("ip")
416 .and_then(|v| v.as_str())
417 .unwrap_or("")
418 .cmp(b.get("ip").and_then(|v| v.as_str()).unwrap_or(""))
419 });
420 return Value::Array(normalized);
421 }
422 value
423 }
424 "cname" | "ns" => {
425 let host = value
426 .get("host")
427 .and_then(|v| v.as_str())
428 .map(strip_trailing_dot)
429 .unwrap_or_default();
430 serde_json::json!({ "host": host })
431 }
432 "mx" => {
433 let host = value
434 .get("host")
435 .and_then(|v| v.as_str())
436 .map(strip_trailing_dot)
437 .unwrap_or_default();
438 let priority = value.get("priority").and_then(|v| v.as_u64()).unwrap_or(0);
439 serde_json::json!({ "host": host, "priority": priority })
440 }
441 "txt" => {
442 let text = value
443 .get("text")
444 .and_then(|v| v.as_str())
445 .unwrap_or("")
446 .to_string();
447 serde_json::json!({ "text": text })
448 }
449 "srv" => {
450 let target = value
451 .get("target")
452 .and_then(|v| v.as_str())
453 .map(strip_trailing_dot)
454 .unwrap_or_default();
455 let priority = value.get("priority").and_then(|v| v.as_u64()).unwrap_or(0);
456 let weight = value.get("weight").and_then(|v| v.as_u64()).unwrap_or(0);
457 let port = value.get("port").and_then(|v| v.as_u64()).unwrap_or(0);
458 serde_json::json!({
459 "target": target,
460 "priority": priority,
461 "weight": weight,
462 "port": port,
463 })
464 }
465 "tlsa" => {
466 let usage = value.get("usage").and_then(|v| v.as_u64()).unwrap_or(0);
467 let selector = value.get("selector").and_then(|v| v.as_u64()).unwrap_or(0);
468 let matching_type = value
469 .get("matching_type")
470 .and_then(|v| v.as_u64())
471 .unwrap_or(0);
472 let certificate = value
473 .get("certificate")
474 .and_then(|v| v.as_str())
475 .unwrap_or("")
476 .to_ascii_lowercase();
477 serde_json::json!({
478 "usage": usage,
479 "selector": selector,
480 "matching_type": matching_type,
481 "certificate": certificate,
482 })
483 }
484 "caa" => {
485 let flag = value.get("flag").and_then(|v| v.as_u64()).unwrap_or(0);
486 let tag = value
487 .get("tag")
488 .and_then(|v| v.as_str())
489 .unwrap_or("")
490 .to_string();
491 let v = value
492 .get("value")
493 .and_then(|v| v.as_str())
494 .unwrap_or("")
495 .to_string();
496 serde_json::json!({ "flag": flag, "tag": tag, "value": v })
497 }
498 _ => value,
499 }
500}
501
502fn strip_trailing_dot(s: &str) -> String {
503 s.strip_suffix('.').unwrap_or(s).to_string()
504}
505
506fn listed_to_dns_record(record_type: DnsRecordType, value: &Value) -> Option<DnsRecord> {
507 use crate::{CAARecord, KeyValue, MXRecord, SRVRecord, TLSARecord};
508
509 match record_type {
510 DnsRecordType::A => {
511 let items = value.as_array()?;
512 let ip = items.first()?.get("ip")?.as_str()?;
513 ip.parse().ok().map(DnsRecord::A)
514 }
515 DnsRecordType::AAAA => {
516 let items = value.as_array()?;
517 let ip = items.first()?.get("ip")?.as_str()?;
518 ip.parse().ok().map(DnsRecord::AAAA)
519 }
520 DnsRecordType::CNAME => {
521 let host = value.get("host")?.as_str()?;
522 Some(DnsRecord::CNAME(strip_trailing_dot(host)))
523 }
524 DnsRecordType::NS => {
525 let host = value.get("host")?.as_str()?;
526 Some(DnsRecord::NS(strip_trailing_dot(host)))
527 }
528 DnsRecordType::MX => {
529 let host = value.get("host")?.as_str()?;
530 let priority = value.get("priority")?.as_u64()? as u16;
531 Some(DnsRecord::MX(MXRecord {
532 exchange: strip_trailing_dot(host),
533 priority,
534 }))
535 }
536 DnsRecordType::TXT => {
537 let text = value.get("text")?.as_str()?;
538 Some(DnsRecord::TXT(text.to_string()))
539 }
540 DnsRecordType::SRV => {
541 let target = value.get("target")?.as_str()?;
542 let priority = value.get("priority")?.as_u64()? as u16;
543 let weight = value.get("weight")?.as_u64()? as u16;
544 let port = value.get("port")?.as_u64()? as u16;
545 Some(DnsRecord::SRV(SRVRecord {
546 target: strip_trailing_dot(target),
547 priority,
548 weight,
549 port,
550 }))
551 }
552 DnsRecordType::TLSA => {
553 let usage = value.get("usage")?.as_u64()? as u8;
554 let selector = value.get("selector")?.as_u64()? as u8;
555 let matching = value.get("matching_type")?.as_u64()? as u8;
556 let hex = value.get("certificate")?.as_str()?;
557 let cert_data = decode_hex(hex)?;
558 let cert_usage = match usage {
559 0 => crate::TlsaCertUsage::PkixTa,
560 1 => crate::TlsaCertUsage::PkixEe,
561 2 => crate::TlsaCertUsage::DaneTa,
562 3 => crate::TlsaCertUsage::DaneEe,
563 _ => crate::TlsaCertUsage::Private,
564 };
565 let selector = match selector {
566 0 => crate::TlsaSelector::Full,
567 1 => crate::TlsaSelector::Spki,
568 _ => crate::TlsaSelector::Private,
569 };
570 let matching = match matching {
571 0 => crate::TlsaMatching::Raw,
572 1 => crate::TlsaMatching::Sha256,
573 2 => crate::TlsaMatching::Sha512,
574 _ => crate::TlsaMatching::Private,
575 };
576 Some(DnsRecord::TLSA(TLSARecord {
577 cert_usage,
578 selector,
579 matching,
580 cert_data,
581 }))
582 }
583 DnsRecordType::CAA => {
584 let flag = value.get("flag")?.as_u64()? as u8;
585 let tag = value.get("tag")?.as_str()?.to_string();
586 let v = value.get("value")?.as_str()?.to_string();
587 let issuer_critical = flag & 0x80 != 0;
588 let parse_options = |target: &str| -> (Option<String>, Vec<KeyValue>) {
589 let mut parts = target.split(';');
590 let name = parts
591 .next()
592 .map(|s| s.trim().to_string())
593 .filter(|s| !s.is_empty());
594 let options = parts
595 .filter_map(|p| {
596 let p = p.trim();
597 if p.is_empty() {
598 return None;
599 }
600 let (k, val) = p.split_once('=').unwrap_or((p, ""));
601 Some(KeyValue {
602 key: k.trim().to_string(),
603 value: val.trim().to_string(),
604 })
605 })
606 .collect();
607 (name, options)
608 };
609 match tag.as_str() {
610 "issue" => {
611 let (name, options) = parse_options(&v);
612 Some(DnsRecord::CAA(CAARecord::Issue {
613 issuer_critical,
614 name,
615 options,
616 }))
617 }
618 "issuewild" => {
619 let (name, options) = parse_options(&v);
620 Some(DnsRecord::CAA(CAARecord::IssueWild {
621 issuer_critical,
622 name,
623 options,
624 }))
625 }
626 "iodef" => Some(DnsRecord::CAA(CAARecord::Iodef {
627 issuer_critical,
628 url: v,
629 })),
630 _ => None,
631 }
632 }
633 }
634}
635
636fn decode_hex(s: &str) -> Option<Vec<u8>> {
637 if !s.len().is_multiple_of(2) {
638 return None;
639 }
640 let mut out = Vec::with_capacity(s.len() / 2);
641 for chunk in s.as_bytes().chunks(2) {
642 let h = char::from(chunk[0]).to_digit(16)?;
643 let l = char::from(chunk[1]).to_digit(16)?;
644 out.push(((h << 4) | l) as u8);
645 }
646 Some(out)
647}
648
649impl TryFrom<DnsRecord> for ArvanRecordContent {
650 type Error = Error;
651
652 fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
653 match record {
654 DnsRecord::A(addr) => Ok(ArvanRecordContent {
655 record_type: "a",
656 value: serde_json::json!([{
657 "ip": addr.to_string(),
658 "port": serde_json::Value::Null,
659 "weight": 100,
660 "country": "",
661 }]),
662 }),
663 DnsRecord::AAAA(addr) => Ok(ArvanRecordContent {
664 record_type: "aaaa",
665 value: serde_json::json!([{
666 "ip": addr.to_string(),
667 "port": serde_json::Value::Null,
668 "weight": 100,
669 "country": "",
670 }]),
671 }),
672 DnsRecord::CNAME(target) => Ok(ArvanRecordContent {
673 record_type: "cname",
674 value: serde_json::json!({ "host": target }),
675 }),
676 DnsRecord::NS(target) => Ok(ArvanRecordContent {
677 record_type: "ns",
678 value: serde_json::json!({ "host": target }),
679 }),
680 DnsRecord::MX(mx) => Ok(ArvanRecordContent {
681 record_type: "mx",
682 value: serde_json::json!({ "host": mx.exchange, "priority": mx.priority }),
683 }),
684 DnsRecord::TXT(text) => Ok(ArvanRecordContent {
685 record_type: "txt",
686 value: serde_json::json!({ "text": text }),
687 }),
688 DnsRecord::SRV(srv) => Ok(ArvanRecordContent {
689 record_type: "srv",
690 value: serde_json::json!({
691 "target": srv.target,
692 "priority": srv.priority,
693 "weight": srv.weight,
694 "port": srv.port,
695 }),
696 }),
697 DnsRecord::TLSA(tlsa) => {
698 let certificate: String =
699 tlsa.cert_data.iter().map(|b| format!("{b:02x}")).collect();
700 Ok(ArvanRecordContent {
701 record_type: "tlsa",
702 value: serde_json::json!({
703 "usage": u8::from(tlsa.cert_usage),
704 "selector": u8::from(tlsa.selector),
705 "matching_type": u8::from(tlsa.matching),
706 "certificate": certificate,
707 }),
708 })
709 }
710 DnsRecord::CAA(caa) => {
711 let (flags, tag, value) = caa.decompose();
712 Ok(ArvanRecordContent {
713 record_type: "caa",
714 value: serde_json::json!({
715 "flag": flags,
716 "tag": tag,
717 "value": value,
718 }),
719 })
720 }
721 }
722 }
723}