Skip to main content

dns_update/providers/
linode.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    DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
14    utils::strip_origin_from_name,
15};
16use serde::{Deserialize, Serialize};
17use std::{borrow::Cow, time::Duration};
18
19const DEFAULT_API_ENDPOINT: &str = "https://api.linode.com/v4";
20
21#[derive(Clone)]
22pub struct LinodeProvider {
23    client: HttpClientBuilder,
24    endpoint: Cow<'static, str>,
25}
26
27#[derive(Deserialize, Debug)]
28struct PagedDomains {
29    data: Vec<Domain>,
30}
31
32#[derive(Deserialize, Debug)]
33struct Domain {
34    id: i64,
35    domain: String,
36}
37
38#[derive(Deserialize, Debug)]
39struct PagedDomainRecords {
40    data: Vec<DomainRecord>,
41}
42
43#[derive(Deserialize, Debug)]
44struct DomainRecord {
45    id: i64,
46    name: String,
47    #[serde(rename = "type")]
48    record_type: String,
49}
50
51#[derive(Serialize, Debug)]
52struct DomainRecordRequest<'a> {
53    name: &'a str,
54    #[serde(rename = "type")]
55    record_type: &'static str,
56    target: String,
57    ttl_sec: u32,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    priority: Option<u16>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    weight: Option<u16>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    port: Option<u16>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    tag: Option<String>,
66}
67
68impl LinodeProvider {
69    pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
70        let client = HttpClientBuilder::default()
71            .with_header("Authorization", format!("Bearer {}", auth_token.as_ref()))
72            .with_timeout(timeout);
73        Self {
74            client,
75            endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
76        }
77    }
78
79    #[cfg(test)]
80    pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
81        Self {
82            endpoint: endpoint.into(),
83            ..self
84        }
85    }
86
87    pub(crate) async fn create(
88        &self,
89        name: impl IntoFqdn<'_>,
90        record: DnsRecord,
91        ttl: u32,
92        origin: impl IntoFqdn<'_>,
93    ) -> crate::Result<()> {
94        let domain = origin.into_name();
95        let name = name.into_name();
96        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
97        let domain_id = self.obtain_domain_id(&domain).await?;
98        let body = build_request(&subdomain, record, ttl)?;
99
100        self.client
101            .post(format!("{}/domains/{}/records", self.endpoint, domain_id))
102            .with_body(body)?
103            .send_raw()
104            .await
105            .map(|_| ())
106    }
107
108    pub(crate) async fn update(
109        &self,
110        name: impl IntoFqdn<'_>,
111        record: DnsRecord,
112        ttl: u32,
113        origin: impl IntoFqdn<'_>,
114    ) -> crate::Result<()> {
115        let domain = origin.into_name();
116        let name = name.into_name();
117        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
118        let domain_id = self.obtain_domain_id(&domain).await?;
119        let record_id = self
120            .obtain_record_id(domain_id, &subdomain, record.as_type())
121            .await?;
122        let body = build_request(&subdomain, record, ttl)?;
123
124        self.client
125            .put(format!(
126                "{}/domains/{}/records/{}",
127                self.endpoint, domain_id, record_id
128            ))
129            .with_body(body)?
130            .send_raw()
131            .await
132            .map(|_| ())
133    }
134
135    pub(crate) async fn delete(
136        &self,
137        name: impl IntoFqdn<'_>,
138        origin: impl IntoFqdn<'_>,
139        record_type: DnsRecordType,
140    ) -> crate::Result<()> {
141        let domain = origin.into_name();
142        let name = name.into_name();
143        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
144        let domain_id = self.obtain_domain_id(&domain).await?;
145        let record_id = self
146            .obtain_record_id(domain_id, &subdomain, record_type)
147            .await?;
148
149        self.client
150            .delete(format!(
151                "{}/domains/{}/records/{}",
152                self.endpoint, domain_id, record_id
153            ))
154            .send_raw()
155            .await
156            .map(|_| ())
157    }
158
159    async fn obtain_domain_id(&self, domain: &str) -> crate::Result<i64> {
160        self.client
161            .get(format!("{}/domains", self.endpoint))
162            .with_header("X-Filter", format!(r#"{{"domain":"{}"}}"#, domain))
163            .send_with_retry::<PagedDomains>(3)
164            .await
165            .and_then(|response| {
166                response
167                    .data
168                    .into_iter()
169                    .find(|d| d.domain == domain)
170                    .map(|d| d.id)
171                    .ok_or_else(|| Error::Api(format!("Linode domain {domain} not found")))
172            })
173    }
174
175    async fn obtain_record_id(
176        &self,
177        domain_id: i64,
178        subdomain: &str,
179        record_type: DnsRecordType,
180    ) -> crate::Result<i64> {
181        let wanted_type = record_type.as_str();
182        self.client
183            .get(format!(
184                "{}/domains/{}/records",
185                self.endpoint, domain_id
186            ))
187            .send_with_retry::<PagedDomainRecords>(3)
188            .await
189            .and_then(|response| {
190                response
191                    .data
192                    .into_iter()
193                    .find(|r| r.name == subdomain && r.record_type == wanted_type)
194                    .map(|r| r.id)
195                    .ok_or_else(|| {
196                        Error::Api(format!(
197                            "DNS Record {subdomain} of type {wanted_type} not found"
198                        ))
199                    })
200            })
201    }
202}
203
204fn build_request<'a>(
205    subdomain: &'a str,
206    record: DnsRecord,
207    ttl: u32,
208) -> crate::Result<DomainRecordRequest<'a>> {
209    let record_type = record.as_type().as_str();
210    match record {
211        DnsRecord::A(addr) => Ok(DomainRecordRequest {
212            name: subdomain,
213            record_type,
214            target: addr.to_string(),
215            ttl_sec: ttl,
216            priority: None,
217            weight: None,
218            port: None,
219            tag: None,
220        }),
221        DnsRecord::AAAA(addr) => Ok(DomainRecordRequest {
222            name: subdomain,
223            record_type,
224            target: addr.to_string(),
225            ttl_sec: ttl,
226            priority: None,
227            weight: None,
228            port: None,
229            tag: None,
230        }),
231        DnsRecord::CNAME(content) => Ok(DomainRecordRequest {
232            name: subdomain,
233            record_type,
234            target: content,
235            ttl_sec: ttl,
236            priority: None,
237            weight: None,
238            port: None,
239            tag: None,
240        }),
241        DnsRecord::NS(content) => Ok(DomainRecordRequest {
242            name: subdomain,
243            record_type,
244            target: content,
245            ttl_sec: ttl,
246            priority: None,
247            weight: None,
248            port: None,
249            tag: None,
250        }),
251        DnsRecord::MX(mx) => Ok(DomainRecordRequest {
252            name: subdomain,
253            record_type,
254            target: mx.exchange,
255            ttl_sec: ttl,
256            priority: Some(mx.priority),
257            weight: None,
258            port: None,
259            tag: None,
260        }),
261        DnsRecord::TXT(content) => Ok(DomainRecordRequest {
262            name: subdomain,
263            record_type,
264            target: content,
265            ttl_sec: ttl,
266            priority: None,
267            weight: None,
268            port: None,
269            tag: None,
270        }),
271        DnsRecord::SRV(srv) => Ok(DomainRecordRequest {
272            name: subdomain,
273            record_type,
274            target: srv.target,
275            ttl_sec: ttl,
276            priority: Some(srv.priority),
277            weight: Some(srv.weight),
278            port: Some(srv.port),
279            tag: None,
280        }),
281        DnsRecord::TLSA(_) => Err(Error::Api(
282            "TLSA records are not supported by Linode".to_string(),
283        )),
284        DnsRecord::CAA(caa) => {
285            let (_flags, tag, value) = caa.decompose();
286            Ok(DomainRecordRequest {
287                name: subdomain,
288                record_type,
289                target: value,
290                ttl_sec: ttl,
291                priority: None,
292                weight: None,
293                port: None,
294                tag: Some(tag),
295            })
296        }
297    }
298}