Skip to main content

dns_update/providers/
vercel.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    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}