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