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::{
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}