1use crate::utils::{build_caa, parse_tlsa, strip_origin_from_name, strip_trailing_dot};
13use crate::{
14 DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord,
15 http::{HttpClient, HttpClientBuilder},
16};
17use encodify::base64::STANDARD as BASE64;
18use serde::{Deserialize, Serialize};
19use std::time::Duration;
20
21const DEFAULT_API_ENDPOINT: &str = "https://api.simply.com/2";
22
23#[derive(Clone)]
24pub struct SimplyComProvider {
25 client: HttpClient,
26 endpoint: String,
27}
28
29#[derive(Deserialize, Debug, Clone)]
30pub struct ProductList {
31 #[serde(default)]
32 pub products: Vec<Product>,
33}
34
35#[derive(Deserialize, Debug, Clone)]
36pub struct Product {
37 pub object: String,
38 #[serde(default)]
39 pub name: Option<String>,
40 #[serde(default)]
41 pub domain: Option<ProductDomain>,
42}
43
44#[derive(Deserialize, Debug, Clone)]
45pub struct ProductDomain {
46 #[serde(default)]
47 pub name: Option<String>,
48 #[serde(default)]
49 pub name_idn: Option<String>,
50}
51
52#[derive(Deserialize, Debug, Clone)]
53pub struct RecordList {
54 #[serde(default)]
55 pub records: Vec<ExistingDnsRecord>,
56}
57
58#[derive(Deserialize, Debug, Clone)]
59pub struct ExistingDnsRecord {
60 pub record_id: i64,
61 pub name: String,
62 #[serde(rename = "type")]
63 pub record_type: String,
64 #[serde(default)]
65 pub data: String,
66 #[serde(default)]
67 pub priority: Option<u16>,
68}
69
70#[derive(Serialize, Debug, Clone)]
71pub struct CreateDnsRecord<'a> {
72 #[serde(rename = "type")]
73 pub record_type: &'static str,
74 pub name: &'a str,
75 pub data: &'a str,
76 pub ttl: u32,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub priority: Option<u16>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct SimplyComRecordContent {
83 pub record_type: &'static str,
84 pub data: String,
85 pub priority: Option<u16>,
86}
87
88impl SimplyComProvider {
89 pub(crate) fn new(
90 account_name: impl AsRef<str>,
91 api_key: impl AsRef<str>,
92 timeout: Option<Duration>,
93 ) -> Self {
94 let credentials = format!("{}:{}", account_name.as_ref(), api_key.as_ref());
95 let mut authorization = String::from("Basic ");
96 BASE64.encode_append(credentials, &mut authorization);
97 let client = HttpClientBuilder::default()
98 .with_header("Authorization", authorization)
99 .with_timeout(timeout)
100 .build();
101 Self {
102 client,
103 endpoint: DEFAULT_API_ENDPOINT.to_string(),
104 }
105 }
106
107 #[cfg(test)]
108 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
109 Self {
110 endpoint: endpoint.as_ref().to_string(),
111 ..self
112 }
113 }
114
115 pub(crate) async fn set_rrset(
116 &self,
117 name: impl IntoFqdn<'_>,
118 record_type: DnsRecordType,
119 ttl: u32,
120 records: Vec<DnsRecord>,
121 origin: impl IntoFqdn<'_>,
122 ) -> crate::Result<()> {
123 let name = name.into_name().into_owned();
124 let origin = origin.into_name().into_owned();
125 let desired = build_contents(record_type, records)?;
126 let (object, zone) = self.find_object(&origin).await?;
127 let host = strip_origin_from_name(&name, &zone, Some("@"));
128 let existing = self.list_at(&object, &host, record_type).await?;
129
130 let mut existing_pool = existing;
131 let mut to_add: Vec<SimplyComRecordContent> = Vec::new();
132
133 for content in desired {
134 if let Some(idx) = existing_pool
135 .iter()
136 .position(|r| record_matches(r, &content))
137 {
138 existing_pool.swap_remove(idx);
139 } else {
140 to_add.push(content);
141 }
142 }
143
144 for entry in existing_pool {
145 self.delete_record(&object, entry.record_id).await?;
146 }
147 for content in to_add {
148 self.create_record(&object, &host, ttl, &content).await?;
149 }
150 Ok(())
151 }
152
153 pub(crate) async fn add_to_rrset(
154 &self,
155 name: impl IntoFqdn<'_>,
156 record_type: DnsRecordType,
157 ttl: u32,
158 records: Vec<DnsRecord>,
159 origin: impl IntoFqdn<'_>,
160 ) -> crate::Result<()> {
161 if records.is_empty() {
162 return Ok(());
163 }
164 let name = name.into_name().into_owned();
165 let origin = origin.into_name().into_owned();
166 let desired = build_contents(record_type, records)?;
167 let (object, zone) = self.find_object(&origin).await?;
168 let host = strip_origin_from_name(&name, &zone, Some("@"));
169 let existing = self.list_at(&object, &host, record_type).await?;
170
171 for content in desired {
172 if existing.iter().any(|r| record_matches(r, &content)) {
173 continue;
174 }
175 self.create_record(&object, &host, ttl, &content).await?;
176 }
177 Ok(())
178 }
179
180 pub(crate) async fn remove_from_rrset(
181 &self,
182 name: impl IntoFqdn<'_>,
183 record_type: DnsRecordType,
184 records: Vec<DnsRecord>,
185 origin: impl IntoFqdn<'_>,
186 ) -> crate::Result<()> {
187 if records.is_empty() {
188 return Ok(());
189 }
190 let name = name.into_name().into_owned();
191 let origin = origin.into_name().into_owned();
192 let to_remove = build_contents(record_type, records)?;
193 let (object, zone) = self.find_object(&origin).await?;
194 let host = strip_origin_from_name(&name, &zone, Some("@"));
195 let existing = self.list_at(&object, &host, record_type).await?;
196
197 for content in to_remove {
198 if let Some(entry) = existing.iter().find(|r| record_matches(r, &content)) {
199 self.delete_record(&object, entry.record_id).await?;
200 }
201 }
202 Ok(())
203 }
204
205 pub(crate) async fn list_rrset(
206 &self,
207 name: impl IntoFqdn<'_>,
208 record_type: DnsRecordType,
209 origin: impl IntoFqdn<'_>,
210 ) -> crate::Result<Vec<DnsRecord>> {
211 let name = name.into_name().into_owned();
212 let origin = origin.into_name().into_owned();
213 let (object, zone) = self.find_object(&origin).await?;
214 let host = strip_origin_from_name(&name, &zone, Some("@"));
215 let existing = self.list_at(&object, &host, record_type).await?;
216 existing.into_iter().map(DnsRecord::try_from).collect()
217 }
218
219 async fn find_object(&self, origin: &str) -> crate::Result<(String, String)> {
220 let response: ProductList = self
221 .client
222 .get(format!("{endpoint}/my/products/", endpoint = self.endpoint))
223 .send_with_retry(3)
224 .await?;
225 let mut candidate = origin;
226 loop {
227 if let Some(product) = response
228 .products
229 .iter()
230 .find(|p| product_matches(p, candidate))
231 {
232 return Ok((product.object.clone(), candidate.to_string()));
233 }
234 match candidate.split_once('.') {
235 Some((_, rest)) if rest.contains('.') => candidate = rest,
236 _ => return Err(Error::NotFound),
237 }
238 }
239 }
240
241 async fn list_at(
242 &self,
243 object: &str,
244 host: &str,
245 record_type: DnsRecordType,
246 ) -> crate::Result<Vec<ExistingDnsRecord>> {
247 let response: RecordList = self
248 .client
249 .get(format!(
250 "{endpoint}/my/products/{object}/dns/records/",
251 endpoint = self.endpoint
252 ))
253 .send_with_retry(3)
254 .await?;
255 let type_str = record_type.as_str();
256 Ok(response
257 .records
258 .into_iter()
259 .filter(|r| r.name == host && r.record_type == type_str)
260 .collect())
261 }
262
263 async fn create_record(
264 &self,
265 object: &str,
266 host: &str,
267 ttl: u32,
268 content: &SimplyComRecordContent,
269 ) -> crate::Result<()> {
270 self.client
271 .post(format!(
272 "{endpoint}/my/products/{object}/dns/records/",
273 endpoint = self.endpoint
274 ))
275 .with_body(CreateDnsRecord {
276 record_type: content.record_type,
277 name: host,
278 data: &content.data,
279 ttl,
280 priority: content.priority,
281 })?
282 .send_with_retry::<serde_json::Value>(3)
283 .await
284 .map(|_| ())
285 }
286
287 async fn delete_record(&self, object: &str, record_id: i64) -> crate::Result<()> {
288 self.client
289 .delete(format!(
290 "{endpoint}/my/products/{object}/dns/records/{record_id}/",
291 endpoint = self.endpoint
292 ))
293 .send_with_retry::<serde_json::Value>(3)
294 .await
295 .map(|_| ())
296 }
297}
298
299fn product_matches(product: &Product, candidate: &str) -> bool {
300 product.object == candidate
301 || product.name.as_deref() == Some(candidate)
302 || product.domain.as_ref().is_some_and(|d| {
303 d.name.as_deref() == Some(candidate) || d.name_idn.as_deref() == Some(candidate)
304 })
305}
306
307fn build_contents(
308 expected_type: DnsRecordType,
309 records: Vec<DnsRecord>,
310) -> crate::Result<Vec<SimplyComRecordContent>> {
311 let mut out = Vec::with_capacity(records.len());
312 for record in records {
313 if record.as_type() != expected_type {
314 return Err(Error::Api(format!(
315 "RRSet record type mismatch: expected {}, got {}",
316 expected_type.as_str(),
317 record.as_type().as_str(),
318 )));
319 }
320 out.push(SimplyComRecordContent::try_from(record)?);
321 }
322 Ok(out)
323}
324
325fn record_matches(existing: &ExistingDnsRecord, desired: &SimplyComRecordContent) -> bool {
326 existing.record_type == desired.record_type
327 && existing.data == desired.data
328 && (desired.priority.is_none() || existing.priority == desired.priority)
329}
330
331fn parse_simplycom_srv(data: &str, priority: u16) -> crate::Result<DnsRecord> {
332 let mut parts = data.split_whitespace();
333 let weight = parts.next().and_then(|v| v.parse().ok());
334 let port = parts.next().and_then(|v| v.parse().ok());
335 let target = parts.next();
336 match (weight, port, target) {
337 (Some(weight), Some(port), Some(target)) if parts.next().is_none() => {
338 Ok(DnsRecord::SRV(SRVRecord {
339 priority,
340 weight,
341 port,
342 target: strip_trailing_dot(target).to_string(),
343 }))
344 }
345 _ => Err(Error::Parse(format!("invalid SRV data: {data}"))),
346 }
347}
348
349fn parse_simplycom_caa(data: &str) -> crate::Result<DnsRecord> {
350 let mut parts = data.splitn(3, ' ');
351 let flags = parts
352 .next()
353 .and_then(|v| v.parse::<u8>().ok())
354 .ok_or_else(|| Error::Parse(format!("invalid CAA data: {data}")))?;
355 let tag = parts
356 .next()
357 .ok_or_else(|| Error::Parse(format!("invalid CAA data: {data}")))?;
358 let value = parts
359 .next()
360 .map(|v| v.trim().trim_matches('"'))
361 .ok_or_else(|| Error::Parse(format!("invalid CAA data: {data}")))?;
362 build_caa(flags, tag, value).map(DnsRecord::CAA)
363}
364
365impl TryFrom<DnsRecord> for SimplyComRecordContent {
366 type Error = Error;
367
368 fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
369 Ok(match record {
370 DnsRecord::A(addr) => SimplyComRecordContent {
371 record_type: "A",
372 data: addr.to_string(),
373 priority: None,
374 },
375 DnsRecord::AAAA(addr) => SimplyComRecordContent {
376 record_type: "AAAA",
377 data: addr.to_string(),
378 priority: None,
379 },
380 DnsRecord::CNAME(target) => SimplyComRecordContent {
381 record_type: "CNAME",
382 data: strip_trailing_dot(&target).to_string(),
383 priority: None,
384 },
385 DnsRecord::NS(target) => SimplyComRecordContent {
386 record_type: "NS",
387 data: strip_trailing_dot(&target).to_string(),
388 priority: None,
389 },
390 DnsRecord::MX(mx) => SimplyComRecordContent {
391 record_type: "MX",
392 data: strip_trailing_dot(&mx.exchange).to_string(),
393 priority: Some(mx.priority),
394 },
395 DnsRecord::TXT(text) => SimplyComRecordContent {
396 record_type: "TXT",
397 data: text,
398 priority: None,
399 },
400 DnsRecord::SRV(srv) => SimplyComRecordContent {
401 record_type: "SRV",
402 data: format!(
403 "{} {} {}",
404 srv.weight,
405 srv.port,
406 strip_trailing_dot(&srv.target)
407 ),
408 priority: Some(srv.priority),
409 },
410 DnsRecord::TLSA(tlsa) => SimplyComRecordContent {
411 record_type: "TLSA",
412 data: tlsa.to_string(),
413 priority: None,
414 },
415 DnsRecord::CAA(caa) => SimplyComRecordContent {
416 record_type: "CAA",
417 data: caa.to_string(),
418 priority: None,
419 },
420 })
421 }
422}
423
424impl TryFrom<ExistingDnsRecord> for DnsRecord {
425 type Error = Error;
426
427 fn try_from(record: ExistingDnsRecord) -> Result<Self, Self::Error> {
428 match record.record_type.as_str() {
429 "A" => record
430 .data
431 .parse()
432 .map(DnsRecord::A)
433 .map_err(|e| Error::Parse(format!("invalid A data: {e}"))),
434 "AAAA" => record
435 .data
436 .parse()
437 .map(DnsRecord::AAAA)
438 .map_err(|e| Error::Parse(format!("invalid AAAA data: {e}"))),
439 "CNAME" => Ok(DnsRecord::CNAME(record.data)),
440 "NS" => Ok(DnsRecord::NS(record.data)),
441 "MX" => Ok(DnsRecord::MX(MXRecord {
442 exchange: record.data,
443 priority: record.priority.unwrap_or_default(),
444 })),
445 "TXT" => Ok(DnsRecord::TXT(record.data)),
446 "SRV" => parse_simplycom_srv(&record.data, record.priority.unwrap_or_default()),
447 "TLSA" => parse_tlsa(&record.data),
448 "CAA" => parse_simplycom_caa(&record.data),
449 other => Err(Error::Parse(format!(
450 "Unsupported Simply.com record type: {other}"
451 ))),
452 }
453 }
454}