Skip to main content

dns_update/providers/
arvancloud.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use 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}