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    CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
14    http::{HttpClient, HttpClientBuilder},
15    utils::strip_origin_from_name,
16};
17use serde::{Deserialize, Serialize};
18use std::{borrow::Cow, time::Duration};
19
20const DEFAULT_API_ENDPOINT: &str = "https://api.linode.com/v4";
21const LIST_PAGE_SIZE: u32 = 500;
22
23#[derive(Clone)]
24pub struct LinodeProvider {
25    client: HttpClient,
26    endpoint: Cow<'static, str>,
27}
28
29#[derive(Deserialize, Debug)]
30struct PagedDomains {
31    data: Vec<Domain>,
32}
33
34#[derive(Deserialize, Debug)]
35struct Domain {
36    id: i64,
37    domain: String,
38}
39
40#[derive(Deserialize, Debug)]
41struct PagedDomainRecords {
42    data: Vec<DomainRecord>,
43    page: u32,
44    pages: u32,
45}
46
47#[derive(Deserialize, Debug, Clone)]
48struct DomainRecord {
49    id: i64,
50    name: String,
51    #[serde(rename = "type")]
52    record_type: String,
53    #[serde(default)]
54    target: String,
55    #[serde(default)]
56    priority: u16,
57    #[serde(default)]
58    weight: u16,
59    #[serde(default)]
60    port: u16,
61    #[serde(default)]
62    service: Option<String>,
63    #[serde(default)]
64    protocol: Option<String>,
65    #[serde(default)]
66    tag: Option<String>,
67}
68
69#[derive(Serialize, Debug)]
70struct DomainRecordRequest<'a> {
71    name: &'a str,
72    #[serde(rename = "type")]
73    record_type: &'static str,
74    target: String,
75    ttl_sec: u32,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    priority: Option<u16>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    weight: Option<u16>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    port: Option<u16>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    service: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    protocol: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    tag: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91struct RecordValue {
92    target: String,
93    priority: Option<u16>,
94    weight: Option<u16>,
95    port: Option<u16>,
96    service: Option<String>,
97    protocol: Option<String>,
98    tag: Option<String>,
99}
100
101impl LinodeProvider {
102    pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
103        let client = HttpClientBuilder::default()
104            .with_header("Authorization", format!("Bearer {}", auth_token.as_ref()))
105            .with_timeout(timeout)
106            .build();
107        Self {
108            client,
109            endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
110        }
111    }
112
113    #[cfg(test)]
114    pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
115        Self {
116            endpoint: endpoint.into(),
117            ..self
118        }
119    }
120
121    pub(crate) async fn set_rrset(
122        &self,
123        name: impl IntoFqdn<'_>,
124        record_type: DnsRecordType,
125        ttl: u32,
126        records: Vec<DnsRecord>,
127        origin: impl IntoFqdn<'_>,
128    ) -> crate::Result<()> {
129        reject_unsupported(record_type)?;
130        let domain = origin.into_name().into_owned();
131        let name = name.into_name().into_owned();
132        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
133        let domain_id = self.obtain_domain_id(&domain).await?;
134        let desired = build_values(record_type, records)?;
135        let existing = self.list_at(domain_id, &subdomain, record_type).await?;
136
137        let mut existing_pool = existing;
138        let mut to_add: Vec<RecordValue> = Vec::new();
139
140        for value in desired {
141            if let Some(idx) = existing_pool
142                .iter()
143                .position(|r| listed_to_value(r) == value)
144            {
145                existing_pool.swap_remove(idx);
146            } else {
147                to_add.push(value);
148            }
149        }
150
151        for entry in existing_pool {
152            self.delete_record(domain_id, entry.id).await?;
153        }
154        for value in to_add {
155            self.create_record(domain_id, &subdomain, record_type, ttl, value)
156                .await?;
157        }
158        Ok(())
159    }
160
161    pub(crate) async fn add_to_rrset(
162        &self,
163        name: impl IntoFqdn<'_>,
164        record_type: DnsRecordType,
165        ttl: u32,
166        records: Vec<DnsRecord>,
167        origin: impl IntoFqdn<'_>,
168    ) -> crate::Result<()> {
169        if records.is_empty() {
170            return Ok(());
171        }
172        reject_unsupported(record_type)?;
173        let domain = origin.into_name().into_owned();
174        let name = name.into_name().into_owned();
175        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
176        let domain_id = self.obtain_domain_id(&domain).await?;
177        let desired = build_values(record_type, records)?;
178        let existing = self.list_at(domain_id, &subdomain, record_type).await?;
179
180        for value in desired {
181            if existing.iter().any(|r| listed_to_value(r) == value) {
182                continue;
183            }
184            self.create_record(domain_id, &subdomain, record_type, ttl, value)
185                .await?;
186        }
187        Ok(())
188    }
189
190    pub(crate) async fn remove_from_rrset(
191        &self,
192        name: impl IntoFqdn<'_>,
193        record_type: DnsRecordType,
194        records: Vec<DnsRecord>,
195        origin: impl IntoFqdn<'_>,
196    ) -> crate::Result<()> {
197        if records.is_empty() {
198            return Ok(());
199        }
200        reject_unsupported(record_type)?;
201        let domain = origin.into_name().into_owned();
202        let name = name.into_name().into_owned();
203        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
204        let domain_id = self.obtain_domain_id(&domain).await?;
205        let to_remove = build_values(record_type, records)?;
206        let existing = self.list_at(domain_id, &subdomain, record_type).await?;
207
208        for value in to_remove {
209            if let Some(entry) = existing.iter().find(|r| listed_to_value(r) == value) {
210                self.delete_record(domain_id, entry.id).await?;
211            }
212        }
213        Ok(())
214    }
215
216    pub(crate) async fn list_rrset(
217        &self,
218        name: impl IntoFqdn<'_>,
219        record_type: DnsRecordType,
220        origin: impl IntoFqdn<'_>,
221    ) -> crate::Result<Vec<DnsRecord>> {
222        reject_unsupported(record_type)?;
223        let domain = origin.into_name().into_owned();
224        let name = name.into_name().into_owned();
225        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
226        let domain_id = self.obtain_domain_id(&domain).await?;
227        let listed = self.list_at(domain_id, &subdomain, record_type).await?;
228        listed
229            .into_iter()
230            .map(|r| value_to_record(record_type, listed_to_value(&r)))
231            .collect()
232    }
233
234    async fn obtain_domain_id(&self, domain: &str) -> crate::Result<i64> {
235        self.client
236            .get(format!("{}/domains", self.endpoint))
237            .with_header("X-Filter", format!(r#"{{"domain":"{}"}}"#, domain))
238            .send_with_retry::<PagedDomains>(3)
239            .await
240            .and_then(|response| {
241                response
242                    .data
243                    .into_iter()
244                    .find(|d| d.domain == domain)
245                    .map(|d| d.id)
246                    .ok_or_else(|| Error::Api(format!("Linode domain {domain} not found")))
247            })
248    }
249
250    async fn list_at(
251        &self,
252        domain_id: i64,
253        subdomain: &str,
254        record_type: DnsRecordType,
255    ) -> crate::Result<Vec<DomainRecord>> {
256        let wanted_type = record_type.as_str();
257        let mut out: Vec<DomainRecord> = Vec::new();
258        let mut page: u32 = 1;
259        loop {
260            let url = format!(
261                "{}/domains/{}/records?page={}&page_size={}",
262                self.endpoint, domain_id, page, LIST_PAGE_SIZE
263            );
264            let response: PagedDomainRecords = self
265                .client
266                .get(url)
267                .with_header("X-Filter", format!(r#"{{"type":"{}"}}"#, wanted_type))
268                .send_with_retry(3)
269                .await?;
270            let total_pages = response.pages.max(response.page);
271            for r in response.data {
272                if r.record_type == wanted_type && published_name(&r) == subdomain {
273                    out.push(r);
274                }
275            }
276            if page >= total_pages {
277                break;
278            }
279            page += 1;
280        }
281        Ok(out)
282    }
283
284    async fn create_record(
285        &self,
286        domain_id: i64,
287        subdomain: &str,
288        record_type: DnsRecordType,
289        ttl: u32,
290        value: RecordValue,
291    ) -> crate::Result<()> {
292        let (rest_name, service, protocol) = split_srv_name(subdomain, &value);
293        let body = build_request(
294            rest_name,
295            record_type.as_str(),
296            ttl,
297            value,
298            service,
299            protocol,
300        );
301        self.client
302            .post(format!("{}/domains/{}/records", self.endpoint, domain_id))
303            .with_body(body)?
304            .send_raw()
305            .await
306            .map(|_| ())
307    }
308
309    async fn delete_record(&self, domain_id: i64, record_id: i64) -> crate::Result<()> {
310        self.client
311            .delete(format!(
312                "{}/domains/{}/records/{}",
313                self.endpoint, domain_id, record_id
314            ))
315            .send_raw()
316            .await
317            .map(|_| ())
318    }
319}
320
321fn reject_unsupported(record_type: DnsRecordType) -> crate::Result<()> {
322    match record_type {
323        DnsRecordType::TLSA => Err(Error::Unsupported(
324            "TLSA records are not supported by Linode".to_string(),
325        )),
326        _ => Ok(()),
327    }
328}
329
330fn build_values(
331    expected_type: DnsRecordType,
332    records: Vec<DnsRecord>,
333) -> crate::Result<Vec<RecordValue>> {
334    let mut out = Vec::with_capacity(records.len());
335    for record in records {
336        if record.as_type() != expected_type {
337            return Err(Error::Api(format!(
338                "RRSet record type mismatch: expected {}, got {}",
339                expected_type.as_str(),
340                record.as_type().as_str(),
341            )));
342        }
343        out.push(record_to_value(record)?);
344    }
345    Ok(out)
346}
347
348fn record_to_value(record: DnsRecord) -> crate::Result<RecordValue> {
349    match record {
350        DnsRecord::A(addr) => Ok(RecordValue {
351            target: addr.to_string(),
352            priority: None,
353            weight: None,
354            port: None,
355            service: None,
356            protocol: None,
357            tag: None,
358        }),
359        DnsRecord::AAAA(addr) => Ok(RecordValue {
360            target: addr.to_string(),
361            priority: None,
362            weight: None,
363            port: None,
364            service: None,
365            protocol: None,
366            tag: None,
367        }),
368        DnsRecord::CNAME(content) => Ok(RecordValue {
369            target: content,
370            priority: None,
371            weight: None,
372            port: None,
373            service: None,
374            protocol: None,
375            tag: None,
376        }),
377        DnsRecord::NS(content) => Ok(RecordValue {
378            target: content,
379            priority: None,
380            weight: None,
381            port: None,
382            service: None,
383            protocol: None,
384            tag: None,
385        }),
386        DnsRecord::MX(mx) => Ok(RecordValue {
387            target: mx.exchange,
388            priority: Some(mx.priority),
389            weight: None,
390            port: None,
391            service: None,
392            protocol: None,
393            tag: None,
394        }),
395        DnsRecord::TXT(content) => Ok(RecordValue {
396            target: content,
397            priority: None,
398            weight: None,
399            port: None,
400            service: None,
401            protocol: None,
402            tag: None,
403        }),
404        DnsRecord::SRV(srv) => Ok(RecordValue {
405            target: srv.target,
406            priority: Some(srv.priority),
407            weight: Some(srv.weight),
408            port: Some(srv.port),
409            service: None,
410            protocol: None,
411            tag: None,
412        }),
413        DnsRecord::TLSA(_) => Err(Error::Unsupported(
414            "TLSA records are not supported by Linode".to_string(),
415        )),
416        DnsRecord::CAA(caa) => {
417            let (_flags, tag, value) = caa.decompose();
418            Ok(RecordValue {
419                target: value,
420                priority: None,
421                weight: None,
422                port: None,
423                service: None,
424                protocol: None,
425                tag: Some(tag),
426            })
427        }
428    }
429}
430
431fn listed_to_value(record: &DomainRecord) -> RecordValue {
432    let is_srv = record.record_type == "SRV";
433    let is_mx = record.record_type == "MX";
434    let is_caa = record.record_type == "CAA";
435    RecordValue {
436        target: record.target.clone(),
437        priority: if is_mx || is_srv {
438            Some(record.priority)
439        } else {
440            None
441        },
442        weight: if is_srv { Some(record.weight) } else { None },
443        port: if is_srv { Some(record.port) } else { None },
444        service: None,
445        protocol: None,
446        tag: if is_caa { record.tag.clone() } else { None },
447    }
448}
449
450fn value_to_record(record_type: DnsRecordType, value: RecordValue) -> crate::Result<DnsRecord> {
451    match record_type {
452        DnsRecordType::A => value
453            .target
454            .parse()
455            .map(DnsRecord::A)
456            .map_err(|e| Error::Parse(format!("invalid A target {}: {e}", value.target))),
457        DnsRecordType::AAAA => value
458            .target
459            .parse()
460            .map(DnsRecord::AAAA)
461            .map_err(|e| Error::Parse(format!("invalid AAAA target {}: {e}", value.target))),
462        DnsRecordType::CNAME => Ok(DnsRecord::CNAME(value.target)),
463        DnsRecordType::NS => Ok(DnsRecord::NS(value.target)),
464        DnsRecordType::MX => Ok(DnsRecord::MX(MXRecord {
465            exchange: value.target,
466            priority: value.priority.unwrap_or_default(),
467        })),
468        DnsRecordType::TXT => Ok(DnsRecord::TXT(value.target)),
469        DnsRecordType::SRV => Ok(DnsRecord::SRV(SRVRecord {
470            target: value.target,
471            priority: value.priority.unwrap_or_default(),
472            weight: value.weight.unwrap_or_default(),
473            port: value.port.unwrap_or_default(),
474        })),
475        DnsRecordType::TLSA => Err(Error::Unsupported(
476            "TLSA records are not supported by Linode".to_string(),
477        )),
478        DnsRecordType::CAA => {
479            let tag = value.tag.unwrap_or_default();
480            build_caa(tag, value.target).map(DnsRecord::CAA)
481        }
482    }
483}
484
485fn build_request<'a>(
486    name: &'a str,
487    record_type: &'static str,
488    ttl: u32,
489    value: RecordValue,
490    service: Option<String>,
491    protocol: Option<String>,
492) -> DomainRecordRequest<'a> {
493    DomainRecordRequest {
494        name,
495        record_type,
496        target: value.target,
497        ttl_sec: ttl,
498        priority: value.priority,
499        weight: value.weight,
500        port: value.port,
501        service,
502        protocol,
503        tag: value.tag,
504    }
505}
506
507fn split_srv_name<'a>(
508    subdomain: &'a str,
509    value: &RecordValue,
510) -> (&'a str, Option<String>, Option<String>) {
511    if value.port.is_none() && value.weight.is_none() {
512        return (subdomain, None, None);
513    }
514    let mut parts = subdomain.splitn(3, '.');
515    let first = parts.next().unwrap_or("");
516    let second = parts.next().unwrap_or("");
517    let rest = parts.next().unwrap_or("");
518    if first.starts_with('_') && second.starts_with('_') {
519        (rest, Some(first.to_string()), Some(second.to_string()))
520    } else {
521        (subdomain, None, None)
522    }
523}
524
525fn published_name(record: &DomainRecord) -> String {
526    match (record.service.as_deref(), record.protocol.as_deref()) {
527        (Some(service), Some(protocol)) if !service.is_empty() && !protocol.is_empty() => {
528            if record.name.is_empty() {
529                format!("{service}.{protocol}")
530            } else {
531                format!("{service}.{protocol}.{}", record.name)
532            }
533        }
534        _ => record.name.clone(),
535    }
536}
537
538fn build_caa(tag: String, value: String) -> crate::Result<CAARecord> {
539    match tag.as_str() {
540        "issue" => {
541            let (name, options) = parse_caa_value(&value);
542            Ok(CAARecord::Issue {
543                issuer_critical: false,
544                name,
545                options,
546            })
547        }
548        "issuewild" => {
549            let (name, options) = parse_caa_value(&value);
550            Ok(CAARecord::IssueWild {
551                issuer_critical: false,
552                name,
553                options,
554            })
555        }
556        "iodef" => Ok(CAARecord::Iodef {
557            issuer_critical: false,
558            url: value,
559        }),
560        other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
561    }
562}
563
564fn parse_caa_value(value: &str) -> (Option<String>, Vec<KeyValue>) {
565    let mut parts = value.split(';').map(str::trim);
566    let name_part = parts.next().unwrap_or("").trim().to_string();
567    let name = if name_part.is_empty() {
568        None
569    } else {
570        Some(name_part)
571    };
572    let options = parts
573        .filter(|p| !p.is_empty())
574        .map(|p| match p.split_once('=') {
575            Some((k, v)) => KeyValue {
576                key: k.trim().to_string(),
577                value: v.trim().to_string(),
578            },
579            None => KeyValue {
580                key: p.trim().to_string(),
581                value: String::new(),
582            },
583        })
584        .collect();
585    (name, options)
586}