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::{DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder};
13use serde::{Deserialize, Serialize};
14use std::{borrow::Cow, time::Duration};
15
16const DEFAULT_API_ENDPOINT: &str = "https://api.gcore.com/dns";
17
18#[derive(Clone)]
19pub struct GcoreProvider {
20    client: HttpClientBuilder,
21    endpoint: Cow<'static, str>,
22}
23
24#[derive(Deserialize, Debug)]
25struct Zone {
26    name: String,
27}
28
29#[derive(Serialize, Debug)]
30struct RrSet {
31    ttl: u32,
32    resource_records: Vec<ResourceRecord>,
33}
34
35#[derive(Serialize, Debug)]
36struct ResourceRecord {
37    content: Vec<serde_json::Value>,
38}
39
40impl GcoreProvider {
41    pub(crate) fn new(api_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
42        let client = HttpClientBuilder::default()
43            .with_header("Authorization", format!("APIKey {}", api_token.as_ref()))
44            .with_timeout(timeout);
45        Self {
46            client,
47            endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
48        }
49    }
50
51    #[cfg(test)]
52    pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
53        Self {
54            endpoint: endpoint.into(),
55            ..self
56        }
57    }
58
59    pub(crate) async fn create(
60        &self,
61        name: impl IntoFqdn<'_>,
62        record: DnsRecord,
63        ttl: u32,
64        origin: impl IntoFqdn<'_>,
65    ) -> crate::Result<()> {
66        let zone = self.obtain_zone(&origin.into_name()).await?;
67        let fqdn = name.into_name();
68        let record_type = record.as_type().as_str();
69        let body = build_rrset(record, ttl)?;
70        self.client
71            .post(format!(
72                "{}/v2/zones/{}/{}/{}",
73                self.endpoint, zone, fqdn, record_type
74            ))
75            .with_body(body)?
76            .send_raw()
77            .await
78            .map(|_| ())
79    }
80
81    pub(crate) async fn update(
82        &self,
83        name: impl IntoFqdn<'_>,
84        record: DnsRecord,
85        ttl: u32,
86        origin: impl IntoFqdn<'_>,
87    ) -> crate::Result<()> {
88        let zone = self.obtain_zone(&origin.into_name()).await?;
89        let fqdn = name.into_name();
90        let record_type = record.as_type().as_str();
91        let body = build_rrset(record, ttl)?;
92        self.client
93            .put(format!(
94                "{}/v2/zones/{}/{}/{}",
95                self.endpoint, zone, fqdn, record_type
96            ))
97            .with_body(body)?
98            .send_raw()
99            .await
100            .map(|_| ())
101    }
102
103    pub(crate) async fn delete(
104        &self,
105        name: impl IntoFqdn<'_>,
106        origin: impl IntoFqdn<'_>,
107        record_type: DnsRecordType,
108    ) -> crate::Result<()> {
109        let zone = self.obtain_zone(&origin.into_name()).await?;
110        let fqdn = name.into_name();
111        self.client
112            .delete(format!(
113                "{}/v2/zones/{}/{}/{}",
114                self.endpoint,
115                zone,
116                fqdn,
117                record_type.as_str()
118            ))
119            .send_raw()
120            .await
121            .map(|_| ())
122    }
123
124    async fn obtain_zone(&self, origin: &str) -> crate::Result<String> {
125        let mut candidate: &str = origin;
126        loop {
127            let result = self
128                .client
129                .get(format!("{}/v2/zones/{}", self.endpoint, candidate))
130                .send_with_retry::<Zone>(3)
131                .await;
132            match result {
133                Ok(zone) => return Ok(zone.name),
134                Err(Error::NotFound) => {}
135                Err(err) => return Err(err),
136            }
137            match candidate.split_once('.') {
138                Some((_, rest)) if rest.contains('.') => candidate = rest,
139                _ => {
140                    return Err(Error::Api(format!("No Gcore zone found for {origin}")));
141                }
142            }
143        }
144    }
145}
146
147fn build_rrset(record: DnsRecord, ttl: u32) -> crate::Result<RrSet> {
148    use serde_json::Value;
149    let content: Vec<Value> = match record {
150        DnsRecord::A(addr) => vec![Value::String(addr.to_string())],
151        DnsRecord::AAAA(addr) => vec![Value::String(addr.to_string())],
152        DnsRecord::CNAME(content) => vec![Value::String(content)],
153        DnsRecord::NS(content) => vec![Value::String(content)],
154        DnsRecord::MX(mx) => vec![
155            Value::Number(mx.priority.into()),
156            Value::String(mx.exchange),
157        ],
158        DnsRecord::TXT(content) => vec![Value::String(content)],
159        DnsRecord::SRV(srv) => vec![
160            Value::Number(srv.priority.into()),
161            Value::Number(srv.weight.into()),
162            Value::Number(srv.port.into()),
163            Value::String(srv.target),
164        ],
165        DnsRecord::TLSA(tlsa) => vec![Value::String(tlsa.to_string())],
166        DnsRecord::CAA(caa) => {
167            let (flags, tag, value) = caa.decompose();
168            vec![
169                Value::Number(flags.into()),
170                Value::String(tag),
171                Value::String(value),
172            ]
173        }
174    };
175    Ok(RrSet {
176        ttl,
177        resource_records: vec![ResourceRecord { content }],
178    })
179}