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