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