Skip to main content

dns_update/providers/
netlify.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::{
14    DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord,
15    http::{HttpClient, HttpClientBuilder},
16};
17use serde::{Deserialize, Serialize};
18use std::time::Duration;
19
20const DEFAULT_ENDPOINT: &str = "https://api.netlify.com/api/v1";
21
22#[derive(Clone)]
23pub struct NetlifyProvider {
24    client: HttpClient,
25    endpoint: String,
26}
27
28#[derive(Serialize, Debug)]
29struct CreateRecord<'a> {
30    hostname: &'a str,
31    #[serde(rename = "type")]
32    record_type: &'a str,
33    value: String,
34    ttl: u32,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    priority: Option<u16>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    weight: Option<u16>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    port: Option<u16>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    flag: Option<u8>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    tag: Option<String>,
45}
46
47#[derive(Deserialize, Debug, Clone)]
48#[allow(dead_code)]
49struct ListedRecord {
50    #[serde(default)]
51    id: String,
52    #[serde(default)]
53    hostname: String,
54    #[serde(default, rename = "type")]
55    record_type: String,
56    #[serde(default)]
57    value: String,
58    #[serde(default)]
59    ttl: u32,
60    #[serde(default)]
61    priority: Option<u16>,
62    #[serde(default)]
63    weight: Option<u16>,
64    #[serde(default)]
65    port: Option<u16>,
66    #[serde(default)]
67    flag: Option<u8>,
68    #[serde(default)]
69    tag: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73struct RecordContent {
74    value: String,
75    priority: Option<u16>,
76    weight: Option<u16>,
77    port: Option<u16>,
78    flag: Option<u8>,
79    tag: Option<String>,
80}
81
82impl NetlifyProvider {
83    pub(crate) fn new(access_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
84        let client = HttpClientBuilder::default()
85            .with_header("Authorization", format!("Bearer {}", access_token.as_ref()))
86            .with_header("Accept", "application/json")
87            .with_timeout(timeout)
88            .build();
89        Self {
90            client,
91            endpoint: DEFAULT_ENDPOINT.to_string(),
92        }
93    }
94
95    #[cfg(test)]
96    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
97        Self {
98            endpoint: endpoint.as_ref().trim_end_matches('/').to_string(),
99            ..self
100        }
101    }
102
103    pub(crate) async fn set_rrset(
104        &self,
105        name: impl IntoFqdn<'_>,
106        record_type: DnsRecordType,
107        ttl: u32,
108        records: Vec<DnsRecord>,
109        origin: impl IntoFqdn<'_>,
110    ) -> crate::Result<()> {
111        check_record_types(record_type, &records)?;
112        reject_tlsa(record_type)?;
113        let name = name.into_name().into_owned();
114        let zone_id = zone_id_from_origin(&origin.into_name());
115
116        let desired = build_contents(&records)?;
117        let mut existing = self.list_at(&zone_id, &name, record_type.as_str()).await?;
118
119        let mut to_add: Vec<RecordContent> = Vec::new();
120        for content in desired {
121            if let Some(idx) = existing
122                .iter()
123                .position(|r| listed_to_content(r) == content)
124            {
125                existing.swap_remove(idx);
126            } else {
127                to_add.push(content);
128            }
129        }
130
131        for entry in existing {
132            self.delete_by_id(&zone_id, &entry.id).await?;
133        }
134        for content in to_add {
135            self.post_content(&zone_id, &name, record_type.as_str(), ttl, &content)
136                .await?;
137        }
138        Ok(())
139    }
140
141    pub(crate) async fn add_to_rrset(
142        &self,
143        name: impl IntoFqdn<'_>,
144        record_type: DnsRecordType,
145        ttl: u32,
146        records: Vec<DnsRecord>,
147        origin: impl IntoFqdn<'_>,
148    ) -> crate::Result<()> {
149        check_record_types(record_type, &records)?;
150        if records.is_empty() {
151            return Ok(());
152        }
153        reject_tlsa(record_type)?;
154        let name = name.into_name().into_owned();
155        let zone_id = zone_id_from_origin(&origin.into_name());
156
157        let desired = build_contents(&records)?;
158        let existing = self.list_at(&zone_id, &name, record_type.as_str()).await?;
159
160        for content in desired {
161            if existing.iter().any(|r| listed_to_content(r) == content) {
162                continue;
163            }
164            self.post_content(&zone_id, &name, record_type.as_str(), ttl, &content)
165                .await?;
166        }
167        Ok(())
168    }
169
170    pub(crate) async fn remove_from_rrset(
171        &self,
172        name: impl IntoFqdn<'_>,
173        record_type: DnsRecordType,
174        records: Vec<DnsRecord>,
175        origin: impl IntoFqdn<'_>,
176    ) -> crate::Result<()> {
177        check_record_types(record_type, &records)?;
178        if records.is_empty() {
179            return Ok(());
180        }
181        reject_tlsa(record_type)?;
182        let name = name.into_name().into_owned();
183        let zone_id = zone_id_from_origin(&origin.into_name());
184
185        let to_remove = build_contents(&records)?;
186        let existing = self.list_at(&zone_id, &name, record_type.as_str()).await?;
187
188        for content in to_remove {
189            if let Some(entry) = existing.iter().find(|r| listed_to_content(r) == content) {
190                self.delete_by_id(&zone_id, &entry.id).await?;
191            }
192        }
193        Ok(())
194    }
195
196    pub(crate) async fn list_rrset(
197        &self,
198        name: impl IntoFqdn<'_>,
199        record_type: DnsRecordType,
200        origin: impl IntoFqdn<'_>,
201    ) -> crate::Result<Vec<DnsRecord>> {
202        reject_tlsa(record_type)?;
203        let name = name.into_name().into_owned();
204        let zone_id = zone_id_from_origin(&origin.into_name());
205        let existing = self.list_at(&zone_id, &name, record_type.as_str()).await?;
206        existing
207            .into_iter()
208            .map(|r| listed_to_record(record_type, &r))
209            .collect()
210    }
211
212    async fn list_all(&self, zone_id: &str) -> crate::Result<Vec<ListedRecord>> {
213        self.client
214            .get(format!(
215                "{}/dns_zones/{}/dns_records",
216                self.endpoint, zone_id
217            ))
218            .send()
219            .await
220    }
221
222    async fn list_at(
223        &self,
224        zone_id: &str,
225        name: &str,
226        record_type: &str,
227    ) -> crate::Result<Vec<ListedRecord>> {
228        let records = self.list_all(zone_id).await?;
229        Ok(records
230            .into_iter()
231            .filter(|r| {
232                r.hostname.trim_end_matches('.').eq_ignore_ascii_case(name)
233                    && r.record_type.eq_ignore_ascii_case(record_type)
234            })
235            .collect())
236    }
237
238    async fn delete_by_id(&self, zone_id: &str, record_id: &str) -> crate::Result<()> {
239        self.client
240            .delete(format!(
241                "{}/dns_zones/{}/dns_records/{}",
242                self.endpoint, zone_id, record_id
243            ))
244            .send_with_retry::<serde_json::Value>(3)
245            .await
246            .map(|_| ())
247    }
248
249    async fn post_content(
250        &self,
251        zone_id: &str,
252        name: &str,
253        record_type: &str,
254        ttl: u32,
255        content: &RecordContent,
256    ) -> crate::Result<()> {
257        let payload = CreateRecord {
258            hostname: name,
259            record_type,
260            value: content.value.clone(),
261            ttl,
262            priority: content.priority,
263            weight: content.weight,
264            port: content.port,
265            flag: content.flag,
266            tag: content.tag.clone(),
267        };
268        self.client
269            .post(format!(
270                "{}/dns_zones/{}/dns_records",
271                self.endpoint, zone_id
272            ))
273            .with_body(payload)?
274            .send_with_retry::<serde_json::Value>(3)
275            .await
276            .map(|_| ())
277    }
278}
279
280fn zone_id_from_origin(origin: &str) -> String {
281    origin.trim_end_matches('.').replace('.', "_")
282}
283
284fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
285    for r in records {
286        if r.as_type() != expected {
287            return Err(Error::Api(format!(
288                "RRSet record type mismatch: expected {}, got {}",
289                expected.as_str(),
290                r.as_type().as_str(),
291            )));
292        }
293    }
294    Ok(())
295}
296
297fn reject_tlsa(record_type: DnsRecordType) -> crate::Result<()> {
298    if record_type == DnsRecordType::TLSA {
299        return Err(Error::Unsupported(
300            "TLSA records are not supported by Netlify".to_string(),
301        ));
302    }
303    Ok(())
304}
305
306fn build_contents(records: &[DnsRecord]) -> crate::Result<Vec<RecordContent>> {
307    records.iter().map(record_to_content).collect()
308}
309
310fn record_to_content(record: &DnsRecord) -> crate::Result<RecordContent> {
311    let mut content = RecordContent {
312        value: String::new(),
313        priority: None,
314        weight: None,
315        port: None,
316        flag: None,
317        tag: None,
318    };
319    match record {
320        DnsRecord::A(addr) => content.value = addr.to_string(),
321        DnsRecord::AAAA(addr) => content.value = addr.to_string(),
322        DnsRecord::CNAME(value) => content.value = value.clone(),
323        DnsRecord::NS(value) => content.value = value.clone(),
324        DnsRecord::MX(mx) => {
325            content.value = mx.exchange.clone();
326            content.priority = Some(mx.priority);
327        }
328        DnsRecord::TXT(value) => content.value = value.clone(),
329        DnsRecord::SRV(srv) => {
330            content.value = srv.target.clone();
331            content.priority = Some(srv.priority);
332            content.weight = Some(srv.weight);
333            content.port = Some(srv.port);
334        }
335        DnsRecord::CAA(caa) => {
336            let (flags, tag, value) = caa.clone().decompose();
337            content.flag = Some(flags);
338            content.tag = Some(tag);
339            content.value = value;
340        }
341        DnsRecord::TLSA(_) => {
342            return Err(Error::Unsupported(
343                "TLSA records are not supported by Netlify".to_string(),
344            ));
345        }
346    }
347    Ok(content)
348}
349
350fn listed_to_content(r: &ListedRecord) -> RecordContent {
351    RecordContent {
352        value: r.value.clone(),
353        priority: r.priority,
354        weight: r.weight,
355        port: r.port,
356        flag: r.flag,
357        tag: r.tag.clone(),
358    }
359}
360
361fn listed_to_record(record_type: DnsRecordType, r: &ListedRecord) -> crate::Result<DnsRecord> {
362    Ok(match record_type {
363        DnsRecordType::A => DnsRecord::A(
364            r.value
365                .parse()
366                .map_err(|e| Error::Parse(format!("invalid A record value {}: {}", r.value, e)))?,
367        ),
368        DnsRecordType::AAAA => {
369            DnsRecord::AAAA(r.value.parse().map_err(|e| {
370                Error::Parse(format!("invalid AAAA record value {}: {}", r.value, e))
371            })?)
372        }
373        DnsRecordType::CNAME => DnsRecord::CNAME(r.value.clone()),
374        DnsRecordType::NS => DnsRecord::NS(r.value.clone()),
375        DnsRecordType::MX => DnsRecord::MX(MXRecord {
376            exchange: r.value.clone(),
377            priority: r.priority.unwrap_or(0),
378        }),
379        DnsRecordType::TXT => DnsRecord::TXT(r.value.clone()),
380        DnsRecordType::SRV => DnsRecord::SRV(SRVRecord {
381            target: r.value.clone(),
382            priority: r.priority.unwrap_or(0),
383            weight: r.weight.unwrap_or(0),
384            port: r.port.unwrap_or(0),
385        }),
386        DnsRecordType::CAA => DnsRecord::CAA(build_caa(
387            r.flag.unwrap_or(0),
388            r.tag.as_deref().unwrap_or_default(),
389            &r.value,
390        )?),
391        DnsRecordType::TLSA => {
392            return Err(Error::Unsupported(
393                "TLSA records are not supported by Netlify".to_string(),
394            ));
395        }
396    })
397}