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