1use 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}