Skip to main content

dns_update/providers/
luadns.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::utils::split_caa_value;
13use crate::utils::strip_trailing_dot;
14use crate::utils::{parse_mx, parse_srv, parse_tlsa};
15use crate::{
16    CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn,
17    http::{HttpClient, HttpClientBuilder},
18    utils::txt_chunks_to_text,
19};
20use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
21use serde::{Deserialize, Serialize};
22use std::time::Duration;
23
24const DEFAULT_API_ENDPOINT: &str = "https://api.luadns.com";
25const PAGE_SIZE: u32 = 500;
26
27#[derive(Clone)]
28pub struct LuaDnsProvider {
29    client: HttpClient,
30    endpoint: String,
31}
32
33#[derive(Deserialize, Debug, Clone)]
34pub struct LuaZone {
35    pub id: i64,
36    pub name: String,
37}
38
39#[derive(Serialize, Deserialize, Debug, Clone)]
40pub struct LuaRecord {
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub id: Option<i64>,
43    pub name: String,
44    #[serde(rename = "type")]
45    pub rr_type: String,
46    pub content: String,
47    pub ttl: u32,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub zone_id: Option<i64>,
50}
51
52impl LuaDnsProvider {
53    pub(crate) fn new(
54        api_username: impl AsRef<str>,
55        api_token: impl AsRef<str>,
56        timeout: Option<Duration>,
57    ) -> Self {
58        let raw = format!("{}:{}", api_username.as_ref(), api_token.as_ref());
59        let encoded = B64.encode(raw);
60        let client = HttpClientBuilder::default()
61            .with_header("Authorization", format!("Basic {encoded}"))
62            .with_header("Accept", "application/json")
63            .with_timeout(timeout)
64            .build();
65        Self {
66            client,
67            endpoint: DEFAULT_API_ENDPOINT.to_string(),
68        }
69    }
70
71    #[cfg(test)]
72    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
73        Self {
74            endpoint: endpoint.as_ref().to_string(),
75            ..self
76        }
77    }
78
79    async fn list_zones(&self) -> crate::Result<Vec<LuaZone>> {
80        let mut out: Vec<LuaZone> = Vec::new();
81        let mut page: u32 = 1;
82        loop {
83            let chunk: Vec<LuaZone> = self
84                .client
85                .get(format!(
86                    "{}/v1/zones?limit={PAGE_SIZE}&page={page}",
87                    self.endpoint
88                ))
89                .send_with_retry(3)
90                .await?;
91            let received = chunk.len();
92            out.extend(chunk);
93            if (received as u32) < PAGE_SIZE {
94                break;
95            }
96            page += 1;
97        }
98        Ok(out)
99    }
100
101    async fn find_zone(&self, origin: &str) -> crate::Result<LuaZone> {
102        let zones = self.list_zones().await?;
103        zones
104            .into_iter()
105            .find(|z| z.name.eq_ignore_ascii_case(origin))
106            .ok_or_else(|| Error::Api(format!("LuaDNS zone {origin} not found")))
107    }
108
109    async fn list_records(&self, zone_id: i64) -> crate::Result<Vec<LuaRecord>> {
110        let mut out: Vec<LuaRecord> = Vec::new();
111        let mut page: u32 = 1;
112        loop {
113            let chunk: Vec<LuaRecord> = self
114                .client
115                .get(format!(
116                    "{}/v1/zones/{zone_id}/records?limit={PAGE_SIZE}&page={page}",
117                    self.endpoint
118                ))
119                .send_with_retry(3)
120                .await?;
121            let received = chunk.len();
122            out.extend(chunk);
123            if (received as u32) < PAGE_SIZE {
124                break;
125            }
126            page += 1;
127        }
128        Ok(out)
129    }
130
131    async fn list_at(
132        &self,
133        zone_id: i64,
134        fqdn: &str,
135        record_type: DnsRecordType,
136    ) -> crate::Result<Vec<LuaRecord>> {
137        let target = fqdn.trim_end_matches('.');
138        let rr_type = record_type.as_str();
139        let records = self.list_records(zone_id).await?;
140        Ok(records
141            .into_iter()
142            .filter(|r| r.rr_type == rr_type && r.name.trim_end_matches('.') == target)
143            .collect())
144    }
145
146    pub(crate) async fn set_rrset(
147        &self,
148        name: impl IntoFqdn<'_>,
149        record_type: DnsRecordType,
150        ttl: u32,
151        records: Vec<DnsRecord>,
152        origin: impl IntoFqdn<'_>,
153    ) -> crate::Result<()> {
154        check_record_types(record_type, &records)?;
155        let origin_name = origin.into_name().to_string();
156        let zone = self.find_zone(&origin_name).await?;
157        let fqdn = name.into_fqdn().to_string();
158        let desired = build_contents(record_type, records)?;
159        let mut existing_pool = self.list_at(zone.id, &fqdn, record_type).await?;
160
161        let mut to_add = Vec::new();
162        for content in desired {
163            if let Some(idx) = existing_pool.iter().position(|r| r.content == content) {
164                existing_pool.swap_remove(idx);
165            } else {
166                to_add.push(content);
167            }
168        }
169
170        for entry in existing_pool {
171            if let Some(id) = entry.id {
172                self.delete_record(zone.id, id).await?;
173            }
174        }
175        for content in to_add {
176            self.create_raw(zone.id, &fqdn, record_type, ttl, content)
177                .await?;
178        }
179        Ok(())
180    }
181
182    pub(crate) async fn add_to_rrset(
183        &self,
184        name: impl IntoFqdn<'_>,
185        record_type: DnsRecordType,
186        ttl: u32,
187        records: Vec<DnsRecord>,
188        origin: impl IntoFqdn<'_>,
189    ) -> crate::Result<()> {
190        check_record_types(record_type, &records)?;
191        if records.is_empty() {
192            return Ok(());
193        }
194        let origin_name = origin.into_name().to_string();
195        let zone = self.find_zone(&origin_name).await?;
196        let fqdn = name.into_fqdn().to_string();
197        let desired = build_contents(record_type, records)?;
198        let existing = self.list_at(zone.id, &fqdn, record_type).await?;
199
200        for content in desired {
201            if existing.iter().any(|r| r.content == content) {
202                continue;
203            }
204            self.create_raw(zone.id, &fqdn, record_type, ttl, content)
205                .await?;
206        }
207        Ok(())
208    }
209
210    pub(crate) async fn remove_from_rrset(
211        &self,
212        name: impl IntoFqdn<'_>,
213        record_type: DnsRecordType,
214        records: Vec<DnsRecord>,
215        origin: impl IntoFqdn<'_>,
216    ) -> crate::Result<()> {
217        check_record_types(record_type, &records)?;
218        if records.is_empty() {
219            return Ok(());
220        }
221        let origin_name = origin.into_name().to_string();
222        let zone = self.find_zone(&origin_name).await?;
223        let fqdn = name.into_fqdn().to_string();
224        let to_remove = build_contents(record_type, records)?;
225        let existing = self.list_at(zone.id, &fqdn, record_type).await?;
226
227        for content in to_remove {
228            if let Some(entry) = existing.iter().find(|r| r.content == content)
229                && let Some(id) = entry.id
230            {
231                self.delete_record(zone.id, id).await?;
232            }
233        }
234        Ok(())
235    }
236
237    pub(crate) async fn list_rrset(
238        &self,
239        name: impl IntoFqdn<'_>,
240        record_type: DnsRecordType,
241        origin: impl IntoFqdn<'_>,
242    ) -> crate::Result<Vec<DnsRecord>> {
243        let origin_name = origin.into_name().to_string();
244        let zone = self.find_zone(&origin_name).await?;
245        let fqdn = name.into_fqdn().to_string();
246        let listed = self.list_at(zone.id, &fqdn, record_type).await?;
247        listed
248            .into_iter()
249            .map(|r| parse_content(record_type, &r.content))
250            .collect()
251    }
252
253    async fn create_raw(
254        &self,
255        zone_id: i64,
256        fqdn: &str,
257        record_type: DnsRecordType,
258        ttl: u32,
259        content: String,
260    ) -> crate::Result<()> {
261        let body = LuaRecord {
262            id: None,
263            name: fqdn.to_string(),
264            rr_type: record_type.as_str().to_string(),
265            content,
266            ttl,
267            zone_id: None,
268        };
269        self.client
270            .post(format!("{}/v1/zones/{zone_id}/records", self.endpoint))
271            .with_body(&body)?
272            .send_with_retry::<LuaRecord>(3)
273            .await
274            .map(|_| ())
275    }
276
277    async fn delete_record(&self, zone_id: i64, record_id: i64) -> crate::Result<()> {
278        self.client
279            .delete(format!(
280                "{}/v1/zones/{zone_id}/records/{record_id}",
281                self.endpoint
282            ))
283            .send_raw()
284            .await
285            .map(|_| ())
286    }
287}
288
289fn ensure_dot(name: String) -> String {
290    if name.ends_with('.') {
291        name
292    } else {
293        format!("{name}.")
294    }
295}
296
297fn render_content(record: DnsRecord) -> String {
298    match record {
299        DnsRecord::A(addr) => addr.to_string(),
300        DnsRecord::AAAA(addr) => addr.to_string(),
301        DnsRecord::CNAME(content) => ensure_dot(content),
302        DnsRecord::NS(content) => ensure_dot(content),
303        DnsRecord::TXT(content) => {
304            let mut out = String::with_capacity(content.len() + 4);
305            txt_chunks_to_text(&mut out, &content, " ");
306            out
307        }
308        DnsRecord::MX(mx) => format!("{} {}", mx.priority, ensure_dot(mx.exchange)),
309        DnsRecord::SRV(srv) => format!(
310            "{} {} {} {}",
311            srv.priority,
312            srv.weight,
313            srv.port,
314            ensure_dot(srv.target)
315        ),
316        DnsRecord::TLSA(tlsa) => tlsa.to_string(),
317        DnsRecord::CAA(caa) => caa.to_string(),
318    }
319}
320
321fn build_contents(
322    expected_type: DnsRecordType,
323    records: Vec<DnsRecord>,
324) -> crate::Result<Vec<String>> {
325    let mut out = Vec::with_capacity(records.len());
326    for record in records {
327        if record.as_type() != expected_type {
328            return Err(Error::Api(format!(
329                "RRSet record type mismatch: expected {}, got {}",
330                expected_type.as_str(),
331                record.as_type().as_str(),
332            )));
333        }
334        out.push(render_content(record));
335    }
336    Ok(out)
337}
338
339fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
340    for r in records {
341        if r.as_type() != expected {
342            return Err(Error::Api(format!(
343                "RRSet record type mismatch: expected {}, got {}",
344                expected.as_str(),
345                r.as_type().as_str(),
346            )));
347        }
348    }
349    Ok(())
350}
351
352fn parse_content(record_type: DnsRecordType, content: &str) -> crate::Result<DnsRecord> {
353    Ok(match record_type {
354        DnsRecordType::A => DnsRecord::A(
355            content
356                .parse()
357                .map_err(|e| Error::Parse(format!("invalid A value '{content}': {e}")))?,
358        ),
359        DnsRecordType::AAAA => DnsRecord::AAAA(
360            content
361                .parse()
362                .map_err(|e| Error::Parse(format!("invalid AAAA value '{content}': {e}")))?,
363        ),
364        DnsRecordType::CNAME => DnsRecord::CNAME(strip_trailing_dot(content).to_string()),
365        DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(content).to_string()),
366        DnsRecordType::MX => parse_mx(content)?,
367        DnsRecordType::TXT => DnsRecord::TXT(parse_txt(content)),
368        DnsRecordType::SRV => parse_srv(content)?,
369        DnsRecordType::TLSA => parse_tlsa(content)?,
370        DnsRecordType::CAA => parse_caa(content)?,
371    })
372}
373
374fn parse_txt(value: &str) -> String {
375    let trimmed = value.trim();
376    let mut out = String::with_capacity(trimmed.len());
377    let mut bytes = trimmed.bytes().peekable();
378    while let Some(&b) = bytes.peek() {
379        if b != b'"' {
380            bytes.next();
381            continue;
382        }
383        bytes.next();
384        loop {
385            match bytes.next() {
386                Some(b'"') => break,
387                Some(b'\\') => {
388                    if let Some(next) = bytes.next() {
389                        out.push(next as char);
390                    }
391                }
392                Some(other) => out.push(other as char),
393                None => break,
394            }
395        }
396    }
397    if out.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('"') {
398        return trimmed.to_string();
399    }
400    out
401}
402
403fn parse_caa(value: &str) -> crate::Result<DnsRecord> {
404    let mut parts = value.splitn(3, char::is_whitespace);
405    let flags: u8 = parts
406        .next()
407        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
408        .parse()
409        .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
410    let tag = parts
411        .next()
412        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
413        .to_ascii_lowercase();
414    let raw_value = parts
415        .next()
416        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
417        .trim();
418    let unquoted = raw_value
419        .strip_prefix('"')
420        .and_then(|s| s.strip_suffix('"'))
421        .map(|s| s.replace("\\\"", "\""))
422        .unwrap_or_else(|| raw_value.to_string());
423
424    let issuer_critical = flags & 0x80 != 0;
425    match tag.as_str() {
426        "issue" => {
427            let (name, options) = split_caa_value(&unquoted);
428            Ok(DnsRecord::CAA(CAARecord::Issue {
429                issuer_critical,
430                name,
431                options,
432            }))
433        }
434        "issuewild" => {
435            let (name, options) = split_caa_value(&unquoted);
436            Ok(DnsRecord::CAA(CAARecord::IssueWild {
437                issuer_critical,
438                name,
439                options,
440            }))
441        }
442        "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
443            issuer_critical,
444            url: unquoted,
445        })),
446        other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
447    }
448}