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, http::HttpClientBuilder,
14    utils::strip_origin_from_name,
15};
16use serde::{Deserialize, Serialize};
17use std::time::Duration;
18
19const DEFAULT_API_ENDPOINT: &str = "https://napi.arvancloud.ir";
20
21#[derive(Clone)]
22pub struct ArvanCloudProvider {
23    client: HttpClientBuilder,
24    endpoint: String,
25}
26
27#[derive(Serialize, Debug, Clone)]
28pub struct ArvanRecordPayload {
29    #[serde(rename = "type")]
30    pub record_type: &'static str,
31    pub name: String,
32    pub value: serde_json::Value,
33    pub ttl: u32,
34    pub upstream_https: &'static str,
35    pub ip_filter_mode: ArvanIpFilterMode,
36}
37
38#[derive(Serialize, Debug, Clone)]
39pub struct ArvanIpFilterMode {
40    pub count: &'static str,
41    pub order: &'static str,
42    pub geo_filter: &'static str,
43}
44
45impl Default for ArvanIpFilterMode {
46    fn default() -> Self {
47        Self {
48            count: "single",
49            order: "none",
50            geo_filter: "none",
51        }
52    }
53}
54
55#[derive(Deserialize, Debug)]
56pub struct ArvanApiResponse<T> {
57    pub data: T,
58}
59
60#[derive(Deserialize, Debug)]
61pub struct ArvanExistingRecord {
62    pub id: String,
63    pub name: String,
64    #[serde(rename = "type")]
65    pub record_type: String,
66}
67
68pub struct ArvanRecordContent {
69    pub record_type: &'static str,
70    pub value: serde_json::Value,
71}
72
73impl ArvanCloudProvider {
74    pub(crate) fn new(api_key: impl AsRef<str>, timeout: Option<Duration>) -> Self {
75        let client = HttpClientBuilder::default()
76            .with_header("Authorization", api_key.as_ref())
77            .with_timeout(timeout);
78        Self {
79            client,
80            endpoint: DEFAULT_API_ENDPOINT.to_string(),
81        }
82    }
83
84    #[cfg(test)]
85    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
86        Self {
87            endpoint: endpoint.as_ref().to_string(),
88            ..self
89        }
90    }
91
92    pub(crate) async fn create(
93        &self,
94        name: impl IntoFqdn<'_>,
95        record: DnsRecord,
96        ttl: u32,
97        origin: impl IntoFqdn<'_>,
98    ) -> crate::Result<()> {
99        let fqdn = name.into_name();
100        let domain = origin.into_name();
101        let subdomain = strip_origin_from_name(&fqdn, &domain, Some("@"));
102        let content = ArvanRecordContent::try_from(record)?;
103        let body = ArvanRecordPayload {
104            record_type: content.record_type,
105            name: subdomain,
106            value: content.value,
107            ttl,
108            upstream_https: "default",
109            ip_filter_mode: ArvanIpFilterMode::default(),
110        };
111
112        self.client
113            .post(format!(
114                "{endpoint}/cdn/4.0/domains/{domain}/dns-records",
115                endpoint = self.endpoint
116            ))
117            .with_body(&body)?
118            .send_raw()
119            .await
120            .map(|_| ())
121    }
122
123    pub(crate) async fn update(
124        &self,
125        name: impl IntoFqdn<'_>,
126        record: DnsRecord,
127        ttl: u32,
128        origin: impl IntoFqdn<'_>,
129    ) -> crate::Result<()> {
130        let fqdn = name.into_name();
131        let domain = origin.into_name();
132        let subdomain = strip_origin_from_name(&fqdn, &domain, Some("@"));
133        let record_type = record.as_type();
134        let record_id = self
135            .find_record_id(&domain, &subdomain, record_type)
136            .await?;
137        let content = ArvanRecordContent::try_from(record)?;
138        let body = ArvanRecordPayload {
139            record_type: content.record_type,
140            name: subdomain,
141            value: content.value,
142            ttl,
143            upstream_https: "default",
144            ip_filter_mode: ArvanIpFilterMode::default(),
145        };
146
147        self.client
148            .put(format!(
149                "{endpoint}/cdn/4.0/domains/{domain}/dns-records/{record_id}",
150                endpoint = self.endpoint
151            ))
152            .with_body(&body)?
153            .send_raw()
154            .await
155            .map(|_| ())
156    }
157
158    pub(crate) async fn delete(
159        &self,
160        name: impl IntoFqdn<'_>,
161        origin: impl IntoFqdn<'_>,
162        record_type: DnsRecordType,
163    ) -> crate::Result<()> {
164        let fqdn = name.into_name();
165        let domain = origin.into_name();
166        let subdomain = strip_origin_from_name(&fqdn, &domain, Some("@"));
167        let record_id = self
168            .find_record_id(&domain, &subdomain, record_type)
169            .await?;
170
171        self.client
172            .delete(format!(
173                "{endpoint}/cdn/4.0/domains/{domain}/dns-records/{record_id}",
174                endpoint = self.endpoint
175            ))
176            .send_raw()
177            .await
178            .map(|_| ())
179    }
180
181    async fn find_record_id(
182        &self,
183        domain: &str,
184        subdomain: &str,
185        record_type: DnsRecordType,
186    ) -> crate::Result<String> {
187        let response: ArvanApiResponse<Vec<ArvanExistingRecord>> = self
188            .client
189            .get(format!(
190                "{endpoint}/cdn/4.0/domains/{domain}/dns-records",
191                endpoint = self.endpoint
192            ))
193            .send()
194            .await?;
195        let wire_type = record_type_to_wire(record_type);
196        response
197            .data
198            .into_iter()
199            .find(|r| r.name == subdomain && r.record_type == wire_type)
200            .map(|r| r.id)
201            .ok_or_else(|| {
202                Error::Api(format!(
203                    "DNS Record {subdomain} of type {wire_type} not found"
204                ))
205            })
206    }
207}
208
209fn record_type_to_wire(record_type: DnsRecordType) -> &'static str {
210    match record_type {
211        DnsRecordType::A => "a",
212        DnsRecordType::AAAA => "aaaa",
213        DnsRecordType::CNAME => "cname",
214        DnsRecordType::NS => "ns",
215        DnsRecordType::MX => "mx",
216        DnsRecordType::TXT => "txt",
217        DnsRecordType::SRV => "srv",
218        DnsRecordType::TLSA => "tlsa",
219        DnsRecordType::CAA => "caa",
220    }
221}
222
223impl TryFrom<DnsRecord> for ArvanRecordContent {
224    type Error = Error;
225
226    fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
227        match record {
228            DnsRecord::A(addr) => Ok(ArvanRecordContent {
229                record_type: "a",
230                value: serde_json::json!([{ "ip": addr.to_string() }]),
231            }),
232            DnsRecord::AAAA(addr) => Ok(ArvanRecordContent {
233                record_type: "aaaa",
234                value: serde_json::json!([{ "ip": addr.to_string() }]),
235            }),
236            DnsRecord::CNAME(target) => Ok(ArvanRecordContent {
237                record_type: "cname",
238                value: serde_json::json!({ "host": target }),
239            }),
240            DnsRecord::NS(target) => Ok(ArvanRecordContent {
241                record_type: "ns",
242                value: serde_json::json!({ "host": target }),
243            }),
244            DnsRecord::MX(mx) => Ok(ArvanRecordContent {
245                record_type: "mx",
246                value: serde_json::json!({ "host": mx.exchange, "priority": mx.priority }),
247            }),
248            DnsRecord::TXT(text) => Ok(ArvanRecordContent {
249                record_type: "txt",
250                value: serde_json::json!({ "text": text }),
251            }),
252            DnsRecord::SRV(srv) => Ok(ArvanRecordContent {
253                record_type: "srv",
254                value: serde_json::json!({
255                    "target": srv.target,
256                    "priority": srv.priority,
257                    "weight": srv.weight,
258                    "port": srv.port,
259                }),
260            }),
261            DnsRecord::TLSA(_) => Err(Error::Api(
262                "TLSA records are not supported by ArvanCloud".to_string(),
263            )),
264            DnsRecord::CAA(caa) => {
265                let (flags, tag, value) = caa.decompose();
266                Ok(ArvanRecordContent {
267                    record_type: "caa",
268                    value: serde_json::json!({
269                        "flag": flags,
270                        "tag": tag,
271                        "value": value,
272                    }),
273                })
274            }
275        }
276    }
277}