1use crate::{
13 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
14 http::{HttpClient, HttpClientBuilder},
15 utils::strip_origin_from_name,
16};
17use serde::{Deserialize, Serialize};
18use std::{borrow::Cow, time::Duration};
19
20const DEFAULT_API_ENDPOINT: &str = "https://api.vercel.com";
21const LIST_PAGE_LIMIT: u32 = 100;
22
23#[derive(Clone)]
24pub struct VercelProvider {
25 client: HttpClient,
26 endpoint: Cow<'static, str>,
27 team_id: Option<String>,
28}
29
30#[derive(Deserialize, Debug)]
31struct ListRecordsResponse {
32 records: Vec<ListedRecord>,
33 #[serde(default)]
34 pagination: Pagination,
35}
36
37#[derive(Deserialize, Debug, Default)]
38struct Pagination {
39 #[serde(default)]
40 next: Option<u64>,
41}
42
43#[derive(Deserialize, Debug, Clone)]
44struct ListedRecord {
45 id: String,
46 name: String,
47 #[serde(rename = "type")]
48 record_type: String,
49 #[serde(default)]
50 value: Option<String>,
51 #[serde(default, rename = "mxPriority")]
52 mx_priority: Option<u16>,
53 #[serde(default)]
54 srv: Option<SrvData>,
55}
56
57#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
58struct SrvData {
59 priority: u16,
60 weight: u16,
61 port: u16,
62 target: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66enum VercelContent {
67 Value(String),
68 Mx { value: String, priority: u16 },
69 Srv(SrvData),
70}
71
72#[derive(Serialize, Debug)]
73struct CreateBody<'a> {
74 name: &'a str,
75 #[serde(rename = "type")]
76 record_type: &'static str,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 value: Option<&'a str>,
79 ttl: u32,
80 #[serde(rename = "mxPriority", skip_serializing_if = "Option::is_none")]
81 mx_priority: Option<u16>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 srv: Option<&'a SrvData>,
84}
85
86impl VercelProvider {
87 pub(crate) fn new(
88 auth_token: impl AsRef<str>,
89 team_id: Option<impl AsRef<str>>,
90 timeout: Option<Duration>,
91 ) -> Self {
92 let client = HttpClientBuilder::default()
93 .with_header("Authorization", format!("Bearer {}", auth_token.as_ref()))
94 .with_timeout(timeout)
95 .build();
96 Self {
97 client,
98 endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
99 team_id: team_id.map(|t| t.as_ref().to_string()),
100 }
101 }
102
103 #[cfg(test)]
104 pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
105 Self {
106 endpoint: endpoint.into(),
107 ..self
108 }
109 }
110
111 fn append_team_query(&self, mut url: String) -> String {
112 if let Some(team_id) = &self.team_id {
113 if url.contains('?') {
114 url.push('&');
115 } else {
116 url.push('?');
117 }
118 url.push_str("teamId=");
119 url.push_str(team_id);
120 }
121 url
122 }
123
124 pub(crate) async fn set_rrset(
125 &self,
126 name: impl IntoFqdn<'_>,
127 record_type: DnsRecordType,
128 ttl: u32,
129 records: Vec<DnsRecord>,
130 origin: impl IntoFqdn<'_>,
131 ) -> crate::Result<()> {
132 let domain = origin.into_name().into_owned();
133 let name = name.into_name().into_owned();
134 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
135 let desired = build_contents(record_type, records)?;
136 let existing = self.list_at(&domain, &subdomain, record_type).await?;
137
138 let mut existing_pool: Vec<(String, VercelContent)> = existing
139 .into_iter()
140 .filter_map(|r| {
141 let id = r.id.clone();
142 listed_to_content(&r, record_type).map(|c| (id, c))
143 })
144 .collect();
145 let mut to_add: Vec<VercelContent> = Vec::new();
146
147 for content in desired {
148 if let Some(idx) = existing_pool.iter().position(|(_, c)| *c == content) {
149 existing_pool.swap_remove(idx);
150 } else {
151 to_add.push(content);
152 }
153 }
154
155 for (id, _) in existing_pool {
156 self.delete_record(&domain, &id).await?;
157 }
158 for content in to_add {
159 self.create_record(&domain, &subdomain, record_type, ttl, &content)
160 .await?;
161 }
162 Ok(())
163 }
164
165 pub(crate) async fn add_to_rrset(
166 &self,
167 name: impl IntoFqdn<'_>,
168 record_type: DnsRecordType,
169 ttl: u32,
170 records: Vec<DnsRecord>,
171 origin: impl IntoFqdn<'_>,
172 ) -> crate::Result<()> {
173 if records.is_empty() {
174 return Ok(());
175 }
176 let domain = origin.into_name().into_owned();
177 let name = name.into_name().into_owned();
178 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
179 let desired = build_contents(record_type, records)?;
180 let existing = self.list_at(&domain, &subdomain, record_type).await?;
181 let mut effective: Vec<VercelContent> = existing
182 .iter()
183 .filter_map(|r| listed_to_content(r, record_type))
184 .collect();
185
186 for content in desired {
187 if effective.contains(&content) {
188 continue;
189 }
190 self.create_record(&domain, &subdomain, record_type, ttl, &content)
191 .await?;
192 effective.push(content);
193 }
194 Ok(())
195 }
196
197 pub(crate) async fn remove_from_rrset(
198 &self,
199 name: impl IntoFqdn<'_>,
200 record_type: DnsRecordType,
201 records: Vec<DnsRecord>,
202 origin: impl IntoFqdn<'_>,
203 ) -> crate::Result<()> {
204 if records.is_empty() {
205 return Ok(());
206 }
207 let domain = origin.into_name().into_owned();
208 let name = name.into_name().into_owned();
209 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
210 let to_remove = build_contents(record_type, records)?;
211 let existing = self.list_at(&domain, &subdomain, record_type).await?;
212 let existing_pairs: Vec<(String, VercelContent)> = existing
213 .into_iter()
214 .filter_map(|r| {
215 let id = r.id.clone();
216 listed_to_content(&r, record_type).map(|c| (id, c))
217 })
218 .collect();
219
220 for content in to_remove {
221 if let Some((id, _)) = existing_pairs.iter().find(|(_, c)| *c == content) {
222 self.delete_record(&domain, id).await?;
223 }
224 }
225 Ok(())
226 }
227
228 pub(crate) async fn list_rrset(
229 &self,
230 name: impl IntoFqdn<'_>,
231 record_type: DnsRecordType,
232 origin: impl IntoFqdn<'_>,
233 ) -> crate::Result<Vec<DnsRecord>> {
234 if record_type == DnsRecordType::TLSA {
235 return Err(Error::Unsupported(
236 "TLSA records are not supported by Vercel".to_string(),
237 ));
238 }
239 let domain = origin.into_name().into_owned();
240 let name = name.into_name().into_owned();
241 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
242 let listed = self.list_at(&domain, &subdomain, record_type).await?;
243 listed
244 .into_iter()
245 .map(|r| listed_to_dns_record(&r, record_type))
246 .collect()
247 }
248
249 async fn list_at(
250 &self,
251 domain: &str,
252 subdomain: &str,
253 record_type: DnsRecordType,
254 ) -> crate::Result<Vec<ListedRecord>> {
255 let wanted_type = record_type.as_str();
256 let mut out: Vec<ListedRecord> = Vec::new();
257 let mut until: Option<u64> = None;
258 loop {
259 let mut url = format!(
260 "{}/v5/domains/{domain}/records?limit={LIST_PAGE_LIMIT}",
261 self.endpoint
262 );
263 if let Some(cursor) = until {
264 url.push_str("&until=");
265 url.push_str(&cursor.to_string());
266 }
267 url = self.append_team_query(url);
268
269 let response: ListRecordsResponse = self.client.get(url).send_with_retry(3).await?;
270 for record in response.records {
271 if record.name == subdomain && record.record_type == wanted_type {
272 out.push(record);
273 }
274 }
275 match response.pagination.next {
276 Some(cursor) => until = Some(cursor),
277 None => break,
278 }
279 }
280 Ok(out)
281 }
282
283 async fn create_record(
284 &self,
285 domain: &str,
286 subdomain: &str,
287 record_type: DnsRecordType,
288 ttl: u32,
289 content: &VercelContent,
290 ) -> crate::Result<()> {
291 let body = build_create_body(subdomain, record_type, ttl, content);
292 let url = self.append_team_query(format!("{}/v2/domains/{domain}/records", self.endpoint));
293 self.client
294 .post(url)
295 .with_body(body)?
296 .send_with_retry::<serde_json::Value>(3)
297 .await
298 .map(|_| ())
299 }
300
301 async fn delete_record(&self, domain: &str, record_id: &str) -> crate::Result<()> {
302 let url = self.append_team_query(format!(
303 "{}/v2/domains/{domain}/records/{record_id}",
304 self.endpoint
305 ));
306 self.client
307 .delete(url)
308 .send_with_retry::<serde_json::Value>(3)
309 .await
310 .map(|_| ())
311 }
312}
313
314fn build_create_body<'a>(
315 subdomain: &'a str,
316 record_type: DnsRecordType,
317 ttl: u32,
318 content: &'a VercelContent,
319) -> CreateBody<'a> {
320 match content {
321 VercelContent::Value(value) => CreateBody {
322 name: subdomain,
323 record_type: record_type.as_str(),
324 value: Some(value.as_str()),
325 ttl,
326 mx_priority: None,
327 srv: None,
328 },
329 VercelContent::Mx { value, priority } => CreateBody {
330 name: subdomain,
331 record_type: record_type.as_str(),
332 value: Some(value.as_str()),
333 ttl,
334 mx_priority: Some(*priority),
335 srv: None,
336 },
337 VercelContent::Srv(data) => CreateBody {
338 name: subdomain,
339 record_type: record_type.as_str(),
340 value: None,
341 ttl,
342 mx_priority: None,
343 srv: Some(data),
344 },
345 }
346}
347
348fn build_contents(
349 expected_type: DnsRecordType,
350 records: Vec<DnsRecord>,
351) -> crate::Result<Vec<VercelContent>> {
352 let mut out = Vec::with_capacity(records.len());
353 for record in records {
354 if record.as_type() != expected_type {
355 return Err(Error::Api(format!(
356 "RRSet record type mismatch: expected {}, got {}",
357 expected_type.as_str(),
358 record.as_type().as_str(),
359 )));
360 }
361 out.push(vercel_content_from_record(&record)?);
362 }
363 Ok(out)
364}
365
366fn vercel_content_from_record(record: &DnsRecord) -> crate::Result<VercelContent> {
367 Ok(match record {
368 DnsRecord::A(addr) => VercelContent::Value(addr.to_string()),
369 DnsRecord::AAAA(addr) => VercelContent::Value(addr.to_string()),
370 DnsRecord::CNAME(content) => VercelContent::Value(content.clone()),
371 DnsRecord::NS(content) => VercelContent::Value(content.clone()),
372 DnsRecord::MX(mx) => VercelContent::Mx {
373 value: mx.exchange.clone(),
374 priority: mx.priority,
375 },
376 DnsRecord::TXT(content) => VercelContent::Value(content.clone()),
377 DnsRecord::SRV(srv) => VercelContent::Srv(SrvData {
378 priority: srv.priority,
379 weight: srv.weight,
380 port: srv.port,
381 target: srv.target.clone(),
382 }),
383 DnsRecord::TLSA(_) => {
384 return Err(Error::Unsupported(
385 "TLSA records are not supported by Vercel".to_string(),
386 ));
387 }
388 DnsRecord::CAA(caa) => VercelContent::Value(caa.to_string()),
389 })
390}
391
392fn listed_to_content(record: &ListedRecord, record_type: DnsRecordType) -> Option<VercelContent> {
393 match record_type {
394 DnsRecordType::MX => Some(VercelContent::Mx {
395 value: record.value.clone().unwrap_or_default(),
396 priority: record.mx_priority.unwrap_or(0),
397 }),
398 DnsRecordType::SRV => {
399 if let Some(srv) = &record.srv {
400 Some(VercelContent::Srv(srv.clone()))
401 } else {
402 parse_srv_value(record.value.as_deref()?).map(VercelContent::Srv)
403 }
404 }
405 DnsRecordType::TLSA => None,
406 _ => Some(VercelContent::Value(
407 record.value.clone().unwrap_or_default(),
408 )),
409 }
410}
411
412fn parse_srv_value(value: &str) -> Option<SrvData> {
413 let mut parts = value.split_whitespace();
414 let priority = parts.next()?.parse().ok()?;
415 let weight = parts.next()?.parse().ok()?;
416 let port = parts.next()?.parse().ok()?;
417 let target = parts.next()?.to_string();
418 if parts.next().is_some() {
419 return None;
420 }
421 Some(SrvData {
422 priority,
423 weight,
424 port,
425 target,
426 })
427}
428
429fn listed_to_dns_record(
430 record: &ListedRecord,
431 record_type: DnsRecordType,
432) -> crate::Result<DnsRecord> {
433 match record_type {
434 DnsRecordType::A => {
435 let value = record.value.as_deref().unwrap_or("");
436 value
437 .parse()
438 .map(DnsRecord::A)
439 .map_err(|err| Error::Parse(format!("invalid A value {value}: {err}")))
440 }
441 DnsRecordType::AAAA => {
442 let value = record.value.as_deref().unwrap_or("");
443 value
444 .parse()
445 .map(DnsRecord::AAAA)
446 .map_err(|err| Error::Parse(format!("invalid AAAA value {value}: {err}")))
447 }
448 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(record.value.clone().unwrap_or_default())),
449 DnsRecordType::NS => Ok(DnsRecord::NS(record.value.clone().unwrap_or_default())),
450 DnsRecordType::MX => Ok(DnsRecord::MX(MXRecord {
451 exchange: record.value.clone().unwrap_or_default(),
452 priority: record.mx_priority.unwrap_or(0),
453 })),
454 DnsRecordType::TXT => Ok(DnsRecord::TXT(record.value.clone().unwrap_or_default())),
455 DnsRecordType::SRV => {
456 let srv = record
457 .srv
458 .clone()
459 .or_else(|| parse_srv_value(record.value.as_deref()?))
460 .ok_or_else(|| {
461 Error::Parse(format!(
462 "invalid SRV record {}: missing srv data",
463 record.id
464 ))
465 })?;
466 Ok(DnsRecord::SRV(SRVRecord {
467 priority: srv.priority,
468 weight: srv.weight,
469 port: srv.port,
470 target: srv.target,
471 }))
472 }
473 DnsRecordType::TLSA => Err(Error::Unsupported(
474 "TLSA records are not supported by Vercel".to_string(),
475 )),
476 DnsRecordType::CAA => parse_caa_value(record.value.as_deref().unwrap_or("")),
477 }
478}
479
480fn parse_caa_value(value: &str) -> crate::Result<DnsRecord> {
481 let mut parts = value.splitn(3, char::is_whitespace);
482 let flags_str = parts
483 .next()
484 .ok_or_else(|| Error::Parse(format!("invalid CAA value: {value}")))?;
485 let tag = parts
486 .next()
487 .ok_or_else(|| Error::Parse(format!("invalid CAA value: {value}")))?;
488 let raw_value = parts.next().unwrap_or("");
489 let flags: u8 = flags_str
490 .parse()
491 .map_err(|err| Error::Parse(format!("invalid CAA flags {flags_str}: {err}")))?;
492 let issuer_critical = flags & 0x80 != 0;
493 let stripped = raw_value
494 .trim()
495 .trim_start_matches('"')
496 .trim_end_matches('"');
497
498 Ok(DnsRecord::CAA(match tag {
499 "issue" => {
500 let (name, options) = split_caa_value(stripped);
501 CAARecord::Issue {
502 issuer_critical,
503 name,
504 options,
505 }
506 }
507 "issuewild" => {
508 let (name, options) = split_caa_value(stripped);
509 CAARecord::IssueWild {
510 issuer_critical,
511 name,
512 options,
513 }
514 }
515 "iodef" => CAARecord::Iodef {
516 issuer_critical,
517 url: stripped.to_string(),
518 },
519 other => return Err(Error::Parse(format!("unknown CAA tag: {other}"))),
520 }))
521}
522
523fn split_caa_value(value: &str) -> (Option<String>, Vec<KeyValue>) {
524 let mut parts = value.split(';').map(str::trim);
525 let name_part = parts.next().unwrap_or("").trim().to_string();
526 let name = if name_part.is_empty() {
527 None
528 } else {
529 Some(name_part)
530 };
531 let options = parts
532 .filter(|p| !p.is_empty())
533 .map(|p| match p.split_once('=') {
534 Some((k, v)) => KeyValue {
535 key: k.trim().to_string(),
536 value: v.trim().to_string(),
537 },
538 None => KeyValue {
539 key: p.trim().to_string(),
540 value: String::new(),
541 },
542 })
543 .collect();
544 (name, options)
545}