1use 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}