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::{DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder};
13use serde::{Deserialize, Serialize};
14use std::time::Duration;
15
16const DEFAULT_ENDPOINT: &str = "https://api.netlify.com/api/v1";
17
18#[derive(Clone)]
19pub struct NetlifyProvider {
20    client: HttpClientBuilder,
21    endpoint: String,
22}
23
24#[derive(Serialize, Debug)]
25struct CreateRecord<'a> {
26    hostname: &'a str,
27    #[serde(rename = "type")]
28    record_type: &'a str,
29    value: String,
30    ttl: u32,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    priority: Option<u16>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    weight: Option<u16>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    port: Option<u16>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    flag: Option<u8>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    tag: Option<String>,
41}
42
43#[derive(Deserialize, Debug, Clone)]
44#[allow(dead_code)]
45struct ListedRecord {
46    #[serde(default)]
47    id: String,
48    #[serde(default)]
49    hostname: String,
50    #[serde(default, rename = "type")]
51    record_type: String,
52    #[serde(default)]
53    value: String,
54}
55
56impl NetlifyProvider {
57    pub(crate) fn new(access_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
58        let client = HttpClientBuilder::default()
59            .with_header("Authorization", format!("Bearer {}", access_token.as_ref()))
60            .with_header("Accept", "application/json")
61            .with_timeout(timeout);
62        Self {
63            client,
64            endpoint: DEFAULT_ENDPOINT.to_string(),
65        }
66    }
67
68    #[cfg(test)]
69    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
70        Self {
71            endpoint: endpoint.as_ref().trim_end_matches('/').to_string(),
72            ..self
73        }
74    }
75
76    pub(crate) async fn create(
77        &self,
78        name: impl IntoFqdn<'_>,
79        record: DnsRecord,
80        ttl: u32,
81        origin: impl IntoFqdn<'_>,
82    ) -> crate::Result<()> {
83        let name = name.into_name().into_owned();
84        let zone_id = zone_id_from_origin(&origin.into_name());
85        let payload = build_create(&record, &name, ttl)?;
86
87        self.client
88            .post(format!(
89                "{}/dns_zones/{}/dns_records",
90                self.endpoint, zone_id
91            ))
92            .with_body(payload)?
93            .send_raw()
94            .await
95            .map(|_| ())
96    }
97
98    pub(crate) async fn update(
99        &self,
100        name: impl IntoFqdn<'_>,
101        record: DnsRecord,
102        ttl: u32,
103        origin: impl IntoFqdn<'_>,
104    ) -> crate::Result<()> {
105        let name = name.into_name().into_owned();
106        let origin = origin.into_name().into_owned();
107        let zone_id = zone_id_from_origin(&origin);
108        let record_type = record.as_type();
109        let record_id = self
110            .find_record_id(&zone_id, &name, record_type.as_str())
111            .await?;
112
113        self.client
114            .delete(format!(
115                "{}/dns_zones/{}/dns_records/{}",
116                self.endpoint, zone_id, record_id
117            ))
118            .send_raw()
119            .await?;
120
121        let payload = build_create(&record, &name, ttl)?;
122        self.client
123            .post(format!(
124                "{}/dns_zones/{}/dns_records",
125                self.endpoint, zone_id
126            ))
127            .with_body(payload)?
128            .send_raw()
129            .await
130            .map(|_| ())
131    }
132
133    pub(crate) async fn delete(
134        &self,
135        name: impl IntoFqdn<'_>,
136        origin: impl IntoFqdn<'_>,
137        record_type: DnsRecordType,
138    ) -> crate::Result<()> {
139        let name = name.into_name().into_owned();
140        let zone_id = zone_id_from_origin(&origin.into_name());
141        let record_id = self
142            .find_record_id(&zone_id, &name, record_type.as_str())
143            .await?;
144
145        self.client
146            .delete(format!(
147                "{}/dns_zones/{}/dns_records/{}",
148                self.endpoint, zone_id, record_id
149            ))
150            .send_raw()
151            .await
152            .map(|_| ())
153    }
154
155    async fn find_record_id(
156        &self,
157        zone_id: &str,
158        name: &str,
159        record_type: &str,
160    ) -> crate::Result<String> {
161        let records: Vec<ListedRecord> = self
162            .client
163            .get(format!(
164                "{}/dns_zones/{}/dns_records",
165                self.endpoint, zone_id
166            ))
167            .send()
168            .await?;
169        records
170            .into_iter()
171            .find(|r| {
172                r.hostname.trim_end_matches('.').eq_ignore_ascii_case(name)
173                    && r.record_type.eq_ignore_ascii_case(record_type)
174            })
175            .map(|r| r.id)
176            .ok_or_else(|| {
177                Error::Api(format!(
178                    "DNS Record {} of type {} not found in Netlify zone",
179                    name, record_type
180                ))
181            })
182    }
183}
184
185fn zone_id_from_origin(origin: &str) -> String {
186    origin.trim_end_matches('.').replace('.', "_")
187}
188
189fn build_create<'a>(
190    record: &'a DnsRecord,
191    name: &'a str,
192    ttl: u32,
193) -> crate::Result<CreateRecord<'a>> {
194    let mut payload = CreateRecord {
195        hostname: name,
196        record_type: "",
197        value: String::new(),
198        ttl,
199        priority: None,
200        weight: None,
201        port: None,
202        flag: None,
203        tag: None,
204    };
205
206    match record {
207        DnsRecord::A(addr) => {
208            payload.record_type = "A";
209            payload.value = addr.to_string();
210        }
211        DnsRecord::AAAA(addr) => {
212            payload.record_type = "AAAA";
213            payload.value = addr.to_string();
214        }
215        DnsRecord::CNAME(value) => {
216            payload.record_type = "CNAME";
217            payload.value = value.clone();
218        }
219        DnsRecord::NS(value) => {
220            payload.record_type = "NS";
221            payload.value = value.clone();
222        }
223        DnsRecord::MX(mx) => {
224            payload.record_type = "MX";
225            payload.value = mx.exchange.clone();
226            payload.priority = Some(mx.priority);
227        }
228        DnsRecord::TXT(value) => {
229            payload.record_type = "TXT";
230            payload.value = value.clone();
231        }
232        DnsRecord::SRV(srv) => {
233            payload.record_type = "SRV";
234            payload.value = srv.target.clone();
235            payload.priority = Some(srv.priority);
236            payload.weight = Some(srv.weight);
237            payload.port = Some(srv.port);
238        }
239        DnsRecord::CAA(caa) => {
240            payload.record_type = "CAA";
241            let (flags, tag, value) = caa.clone().decompose();
242            payload.flag = Some(flags);
243            payload.tag = Some(tag);
244            payload.value = value;
245        }
246        DnsRecord::TLSA(_) => {
247            return Err(Error::Api(
248                "TLSA records are not supported by Netlify".to_string(),
249            ));
250        }
251    }
252
253    Ok(payload)
254}