Skip to main content

dns_update/providers/
gcore.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::utils::build_caa;
13use crate::utils::strip_trailing_dot;
14use crate::utils::{
15    decode_hex, tlsa_cert_usage_from_u8, tlsa_matching_from_u8, tlsa_selector_from_u8,
16};
17use crate::{
18    DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord, TLSARecord,
19    http::{HttpClient, HttpClientBuilder},
20};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::{borrow::Cow, time::Duration};
24
25const DEFAULT_API_ENDPOINT: &str = "https://api.gcore.com/dns";
26
27#[derive(Clone)]
28pub struct GcoreProvider {
29    client: HttpClient,
30    endpoint: Cow<'static, str>,
31}
32
33#[derive(Deserialize, Debug)]
34struct Zone {
35    name: String,
36}
37
38#[derive(Serialize, Debug)]
39struct RrSet {
40    ttl: u32,
41    resource_records: Vec<ResourceRecord>,
42}
43
44#[derive(Serialize, Debug)]
45struct ResourceRecord {
46    content: Vec<Value>,
47}
48
49#[derive(Deserialize, Debug)]
50struct RrSetResponse {
51    #[serde(default)]
52    ttl: u32,
53    #[serde(default)]
54    resource_records: Vec<ResourceRecordResponse>,
55}
56
57#[derive(Deserialize, Debug)]
58struct ResourceRecordResponse {
59    content: Vec<Value>,
60}
61
62impl GcoreProvider {
63    pub(crate) fn new(api_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
64        let client = HttpClientBuilder::default()
65            .with_header("Authorization", format!("APIKey {}", api_token.as_ref()))
66            .with_timeout(timeout)
67            .build();
68        Self {
69            client,
70            endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
71        }
72    }
73
74    #[cfg(test)]
75    pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
76        Self {
77            endpoint: endpoint.into(),
78            ..self
79        }
80    }
81
82    pub(crate) async fn set_rrset(
83        &self,
84        name: impl IntoFqdn<'_>,
85        record_type: DnsRecordType,
86        ttl: u32,
87        records: Vec<DnsRecord>,
88        origin: impl IntoFqdn<'_>,
89    ) -> crate::Result<()> {
90        check_record_types(record_type, &records)?;
91        let zone = self.obtain_zone(&origin.into_name()).await?;
92        let fqdn = name.into_name();
93        let url = self.rrset_url(&zone, &fqdn, record_type);
94
95        if records.is_empty() {
96            return self
97                .client
98                .delete(url)
99                .send_raw()
100                .await
101                .map(|_| ())
102                .or_else(|err| match err {
103                    Error::NotFound => Ok(()),
104                    err => Err(err),
105                });
106        }
107
108        let body = build_rrset_from_many(records, ttl)?;
109        self.put_or_post(url, body).await
110    }
111
112    pub(crate) async fn add_to_rrset(
113        &self,
114        name: impl IntoFqdn<'_>,
115        record_type: DnsRecordType,
116        ttl: u32,
117        records: Vec<DnsRecord>,
118        origin: impl IntoFqdn<'_>,
119    ) -> crate::Result<()> {
120        if records.is_empty() {
121            return Ok(());
122        }
123        check_record_types(record_type, &records)?;
124        let zone = self.obtain_zone(&origin.into_name()).await?;
125        let fqdn = name.into_name();
126        let url = self.rrset_url(&zone, &fqdn, record_type);
127
128        let to_add: Vec<Vec<Value>> = records.into_iter().map(record_to_content).collect();
129
130        let (mut current, existed, effective_ttl) = match self.fetch_rrset(&url).await? {
131            Some(existing) => {
132                let existing_ttl = existing.ttl;
133                let contents = existing
134                    .resource_records
135                    .into_iter()
136                    .map(|r| r.content)
137                    .collect::<Vec<_>>();
138                (contents, true, existing_ttl)
139            }
140            None => (Vec::new(), false, ttl),
141        };
142
143        let before = current.len();
144        for content in to_add {
145            if !current.iter().any(|c| contents_equal(c, &content)) {
146                current.push(content);
147            }
148        }
149
150        if existed && current.len() == before {
151            return Ok(());
152        }
153
154        let body = RrSet {
155            ttl: effective_ttl,
156            resource_records: current
157                .into_iter()
158                .map(|content| ResourceRecord { content })
159                .collect(),
160        };
161        self.put_or_post(url, body).await
162    }
163
164    pub(crate) async fn remove_from_rrset(
165        &self,
166        name: impl IntoFqdn<'_>,
167        record_type: DnsRecordType,
168        records: Vec<DnsRecord>,
169        origin: impl IntoFqdn<'_>,
170    ) -> crate::Result<()> {
171        if records.is_empty() {
172            return Ok(());
173        }
174        check_record_types(record_type, &records)?;
175        let zone = self.obtain_zone(&origin.into_name()).await?;
176        let fqdn = name.into_name();
177        let url = self.rrset_url(&zone, &fqdn, record_type);
178
179        let existing = match self.fetch_rrset(&url).await? {
180            Some(existing) => existing,
181            None => return Ok(()),
182        };
183
184        let to_remove: Vec<Vec<Value>> = records.into_iter().map(record_to_content).collect();
185        let original_len = existing.resource_records.len();
186        let filtered: Vec<Vec<Value>> = existing
187            .resource_records
188            .into_iter()
189            .map(|r| r.content)
190            .filter(|content| !to_remove.iter().any(|r| contents_equal(r, content)))
191            .collect();
192
193        if filtered.len() == original_len {
194            return Ok(());
195        }
196
197        if filtered.is_empty() {
198            return self
199                .client
200                .delete(url)
201                .send_raw()
202                .await
203                .map(|_| ())
204                .or_else(|err| match err {
205                    Error::NotFound => Ok(()),
206                    err => Err(err),
207                });
208        }
209
210        let body = RrSet {
211            ttl: existing.ttl,
212            resource_records: filtered
213                .into_iter()
214                .map(|content| ResourceRecord { content })
215                .collect(),
216        };
217        self.client
218            .put(url)
219            .with_body(body)?
220            .send_raw()
221            .await
222            .map(|_| ())
223    }
224
225    pub(crate) async fn list_rrset(
226        &self,
227        name: impl IntoFqdn<'_>,
228        record_type: DnsRecordType,
229        origin: impl IntoFqdn<'_>,
230    ) -> crate::Result<Vec<DnsRecord>> {
231        let zone = self.obtain_zone(&origin.into_name()).await?;
232        let fqdn = name.into_name();
233        let url = self.rrset_url(&zone, &fqdn, record_type);
234
235        let existing = match self.fetch_rrset(&url).await? {
236            Some(existing) => existing,
237            None => return Ok(Vec::new()),
238        };
239
240        existing
241            .resource_records
242            .into_iter()
243            .map(|r| parse_content(record_type, &r.content))
244            .collect()
245    }
246
247    fn rrset_url(&self, zone: &str, fqdn: &str, record_type: DnsRecordType) -> String {
248        format!(
249            "{}/v2/zones/{}/{}/{}",
250            self.endpoint,
251            zone,
252            fqdn,
253            record_type.as_str()
254        )
255    }
256
257    async fn fetch_rrset(&self, url: &str) -> crate::Result<Option<RrSetResponse>> {
258        match self
259            .client
260            .get(url.to_string())
261            .send_with_retry::<RrSetResponse>(3)
262            .await
263        {
264            Ok(rrset) => Ok(Some(rrset)),
265            Err(Error::NotFound) => Ok(None),
266            Err(err) => Err(err),
267        }
268    }
269
270    async fn put_or_post(&self, url: String, body: RrSet) -> crate::Result<()> {
271        match self
272            .client
273            .put(url.clone())
274            .with_body(&body)?
275            .send_raw()
276            .await
277        {
278            Ok(_) => Ok(()),
279            Err(Error::NotFound) => self
280                .client
281                .post(url)
282                .with_body(body)?
283                .send_raw()
284                .await
285                .map(|_| ()),
286            Err(err) => Err(err),
287        }
288    }
289
290    async fn obtain_zone(&self, origin: &str) -> crate::Result<String> {
291        let mut candidate: &str = origin;
292        loop {
293            let result = self
294                .client
295                .get(format!("{}/v2/zones/{}", self.endpoint, candidate))
296                .send_with_retry::<Zone>(3)
297                .await;
298            match result {
299                Ok(zone) => return Ok(zone.name),
300                Err(Error::NotFound) => {}
301                Err(err) => return Err(err),
302            }
303            match candidate.split_once('.') {
304                Some((_, rest)) if rest.contains('.') => candidate = rest,
305                _ => {
306                    return Err(Error::Api(format!("No Gcore zone found for {origin}")));
307                }
308            }
309        }
310    }
311}
312
313fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
314    for record in records {
315        if record.as_type() != expected {
316            return Err(Error::Api(format!(
317                "RRSet record type mismatch: expected {}, got {}",
318                expected.as_str(),
319                record.as_type().as_str(),
320            )));
321        }
322    }
323    Ok(())
324}
325
326fn build_rrset_from_many(records: Vec<DnsRecord>, ttl: u32) -> crate::Result<RrSet> {
327    Ok(RrSet {
328        ttl,
329        resource_records: records
330            .into_iter()
331            .map(|r| ResourceRecord {
332                content: record_to_content(r),
333            })
334            .collect(),
335    })
336}
337
338fn record_to_content(record: DnsRecord) -> Vec<Value> {
339    match record {
340        DnsRecord::A(addr) => vec![Value::String(addr.to_string())],
341        DnsRecord::AAAA(addr) => vec![Value::String(addr.to_string())],
342        DnsRecord::CNAME(content) => vec![Value::String(content)],
343        DnsRecord::NS(content) => vec![Value::String(content)],
344        DnsRecord::MX(mx) => vec![
345            Value::Number(mx.priority.into()),
346            Value::String(mx.exchange),
347        ],
348        DnsRecord::TXT(content) => vec![Value::String(content)],
349        DnsRecord::SRV(srv) => vec![
350            Value::Number(srv.priority.into()),
351            Value::Number(srv.weight.into()),
352            Value::Number(srv.port.into()),
353            Value::String(srv.target),
354        ],
355        DnsRecord::TLSA(tlsa) => vec![Value::String(tlsa.to_string())],
356        DnsRecord::CAA(caa) => {
357            let (flags, tag, value) = caa.decompose();
358            vec![
359                Value::Number(flags.into()),
360                Value::String(tag),
361                Value::String(value),
362            ]
363        }
364    }
365}
366
367fn contents_equal(a: &[Value], b: &[Value]) -> bool {
368    if a.len() != b.len() {
369        return false;
370    }
371    a.iter().zip(b.iter()).all(|(x, y)| values_equal(x, y))
372}
373
374fn values_equal(a: &Value, b: &Value) -> bool {
375    match (a, b) {
376        (Value::String(x), Value::String(y)) => x == y,
377        (Value::Number(x), Value::Number(y)) => match (x.as_i64(), y.as_i64()) {
378            (Some(xi), Some(yi)) => xi == yi,
379            _ => match (x.as_u64(), y.as_u64()) {
380                (Some(xu), Some(yu)) => xu == yu,
381                _ => match (x.as_f64(), y.as_f64()) {
382                    (Some(xf), Some(yf)) => xf == yf,
383                    _ => false,
384                },
385            },
386        },
387        (Value::Bool(x), Value::Bool(y)) => x == y,
388        (Value::Null, Value::Null) => true,
389        _ => a == b,
390    }
391}
392
393fn parse_content(record_type: DnsRecordType, content: &[Value]) -> crate::Result<DnsRecord> {
394    match record_type {
395        DnsRecordType::A => {
396            let s = expect_string(content, 0, "A")?;
397            s.parse()
398                .map(DnsRecord::A)
399                .map_err(|e| Error::Parse(format!("invalid A record: {e}")))
400        }
401        DnsRecordType::AAAA => {
402            let s = expect_string(content, 0, "AAAA")?;
403            s.parse()
404                .map(DnsRecord::AAAA)
405                .map_err(|e| Error::Parse(format!("invalid AAAA record: {e}")))
406        }
407        DnsRecordType::CNAME => {
408            let s = expect_string(content, 0, "CNAME")?;
409            Ok(DnsRecord::CNAME(strip_trailing_dot(s).to_string()))
410        }
411        DnsRecordType::NS => {
412            let s = expect_string(content, 0, "NS")?;
413            Ok(DnsRecord::NS(strip_trailing_dot(s).to_string()))
414        }
415        DnsRecordType::MX => {
416            if content.len() < 2 {
417                return Err(Error::Parse(format!(
418                    "invalid MX content array length: {}",
419                    content.len()
420                )));
421            }
422            let priority = expect_u16(content, 0, "MX")?;
423            let exchange = expect_string(content, 1, "MX")?;
424            Ok(DnsRecord::MX(MXRecord {
425                priority,
426                exchange: strip_trailing_dot(exchange).to_string(),
427            }))
428        }
429        DnsRecordType::TXT => {
430            let s = expect_string(content, 0, "TXT")?;
431            Ok(DnsRecord::TXT(s.to_string()))
432        }
433        DnsRecordType::SRV => {
434            if content.len() < 4 {
435                return Err(Error::Parse(format!(
436                    "invalid SRV content array length: {}",
437                    content.len()
438                )));
439            }
440            let priority = expect_u16(content, 0, "SRV")?;
441            let weight = expect_u16(content, 1, "SRV")?;
442            let port = expect_u16(content, 2, "SRV")?;
443            let target = expect_string(content, 3, "SRV")?;
444            Ok(DnsRecord::SRV(SRVRecord {
445                priority,
446                weight,
447                port,
448                target: strip_trailing_dot(target).to_string(),
449            }))
450        }
451        DnsRecordType::TLSA => {
452            let s = expect_string(content, 0, "TLSA")?;
453            parse_tlsa_text(s)
454        }
455        DnsRecordType::CAA => {
456            if content.len() < 3 {
457                return Err(Error::Parse(format!(
458                    "invalid CAA content array length: {}",
459                    content.len()
460                )));
461            }
462            let flags = expect_u8(content, 0, "CAA")?;
463            let tag = expect_string(content, 1, "CAA")?.to_string();
464            let value = expect_string(content, 2, "CAA")?.to_string();
465            build_caa(flags, &tag, &value).map(DnsRecord::CAA)
466        }
467    }
468}
469
470fn expect_string<'a>(content: &'a [Value], idx: usize, rtype: &str) -> crate::Result<&'a str> {
471    match content.get(idx) {
472        Some(Value::String(s)) => Ok(s.as_str()),
473        Some(other) => Err(Error::Parse(format!(
474            "expected string at position {idx} for {rtype}, got: {other}"
475        ))),
476        None => Err(Error::Parse(format!(
477            "missing element at position {idx} for {rtype}"
478        ))),
479    }
480}
481
482fn expect_u16(content: &[Value], idx: usize, rtype: &str) -> crate::Result<u16> {
483    match content.get(idx) {
484        Some(Value::Number(n)) => n
485            .as_u64()
486            .and_then(|v| u16::try_from(v).ok())
487            .ok_or_else(|| Error::Parse(format!("invalid u16 at position {idx} for {rtype}: {n}"))),
488        Some(other) => Err(Error::Parse(format!(
489            "expected number at position {idx} for {rtype}, got: {other}"
490        ))),
491        None => Err(Error::Parse(format!(
492            "missing element at position {idx} for {rtype}"
493        ))),
494    }
495}
496
497fn expect_u8(content: &[Value], idx: usize, rtype: &str) -> crate::Result<u8> {
498    match content.get(idx) {
499        Some(Value::Number(n)) => n
500            .as_u64()
501            .and_then(|v| u8::try_from(v).ok())
502            .ok_or_else(|| Error::Parse(format!("invalid u8 at position {idx} for {rtype}: {n}"))),
503        Some(other) => Err(Error::Parse(format!(
504            "expected number at position {idx} for {rtype}, got: {other}"
505        ))),
506        None => Err(Error::Parse(format!(
507            "missing element at position {idx} for {rtype}"
508        ))),
509    }
510}
511
512fn parse_tlsa_text(text: &str) -> crate::Result<DnsRecord> {
513    let mut parts = text.split_whitespace();
514    let usage: u8 = parts
515        .next()
516        .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {text}")))?
517        .parse()
518        .map_err(|e| Error::Parse(format!("invalid TLSA usage: {e}")))?;
519    let selector: u8 = parts
520        .next()
521        .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {text}")))?
522        .parse()
523        .map_err(|e| Error::Parse(format!("invalid TLSA selector: {e}")))?;
524    let matching: u8 = parts
525        .next()
526        .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {text}")))?
527        .parse()
528        .map_err(|e| Error::Parse(format!("invalid TLSA matching: {e}")))?;
529    let hex: String = parts.collect::<Vec<_>>().join("");
530    Ok(DnsRecord::TLSA(TLSARecord {
531        cert_usage: tlsa_cert_usage_from_u8(usage)?,
532        selector: tlsa_selector_from_u8(selector)?,
533        matching: tlsa_matching_from_u8(matching)?,
534        cert_data: decode_hex(&hex)?,
535    }))
536}