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