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::{DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder};
13use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
14use serde::{Deserialize, Serialize};
15use std::time::Duration;
16
17const DEFAULT_API_ENDPOINT: &str = "https://api.luadns.com";
18
19#[derive(Clone)]
20pub struct LuaDnsProvider {
21    client: HttpClientBuilder,
22    endpoint: String,
23}
24
25#[derive(Deserialize, Debug, Clone)]
26pub struct LuaZone {
27    pub id: i64,
28    pub name: String,
29}
30
31#[derive(Serialize, Deserialize, Debug, Clone)]
32pub struct LuaRecord {
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub id: Option<i64>,
35    pub name: String,
36    #[serde(rename = "type")]
37    pub rr_type: String,
38    pub content: String,
39    pub ttl: u32,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub zone_id: Option<i64>,
42}
43
44impl LuaDnsProvider {
45    pub(crate) fn new(
46        api_username: impl AsRef<str>,
47        api_token: impl AsRef<str>,
48        timeout: Option<Duration>,
49    ) -> Self {
50        let raw = format!("{}:{}", api_username.as_ref(), api_token.as_ref());
51        let encoded = B64.encode(raw);
52        let client = HttpClientBuilder::default()
53            .with_header("Authorization", format!("Basic {encoded}"))
54            .with_header("Accept", "application/json")
55            .with_timeout(timeout);
56        Self {
57            client,
58            endpoint: DEFAULT_API_ENDPOINT.to_string(),
59        }
60    }
61
62    #[cfg(test)]
63    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
64        Self {
65            endpoint: endpoint.as_ref().to_string(),
66            ..self
67        }
68    }
69
70    async fn list_zones(&self) -> crate::Result<Vec<LuaZone>> {
71        self.client
72            .get(format!("{}/v1/zones", self.endpoint))
73            .send_with_retry::<Vec<LuaZone>>(3)
74            .await
75    }
76
77    async fn find_zone(&self, origin: &str) -> crate::Result<LuaZone> {
78        let zones = self.list_zones().await?;
79        zones
80            .into_iter()
81            .find(|z| z.name == origin)
82            .ok_or_else(|| Error::Api(format!("LuaDNS zone {origin} not found")))
83    }
84
85    async fn list_records(&self, zone_id: i64) -> crate::Result<Vec<LuaRecord>> {
86        self.client
87            .get(format!("{}/v1/zones/{zone_id}/records", self.endpoint))
88            .send_with_retry::<Vec<LuaRecord>>(3)
89            .await
90    }
91
92    async fn find_record(
93        &self,
94        zone_id: i64,
95        fqdn: &str,
96        record_type: DnsRecordType,
97    ) -> crate::Result<LuaRecord> {
98        let target = fqdn.trim_end_matches('.');
99        let rr_type = record_type.as_str();
100        let records = self.list_records(zone_id).await?;
101        records
102            .into_iter()
103            .find(|r| r.rr_type == rr_type && r.name.trim_end_matches('.') == target)
104            .ok_or_else(|| Error::Api(format!("LuaDNS record {fqdn} of type {rr_type} not found")))
105    }
106
107    pub(crate) async fn create(
108        &self,
109        name: impl IntoFqdn<'_>,
110        record: DnsRecord,
111        ttl: u32,
112        origin: impl IntoFqdn<'_>,
113    ) -> crate::Result<()> {
114        let origin_name = origin.into_name().to_string();
115        let zone = self.find_zone(&origin_name).await?;
116        let body = build_record(name, record, ttl)?;
117
118        self.client
119            .post(format!(
120                "{}/v1/zones/{}/records",
121                self.endpoint, zone.id
122            ))
123            .with_body(&body)?
124            .send_with_retry::<LuaRecord>(3)
125            .await
126            .map(|_| ())
127    }
128
129    pub(crate) async fn update(
130        &self,
131        name: impl IntoFqdn<'_>,
132        record: DnsRecord,
133        ttl: u32,
134        origin: impl IntoFqdn<'_>,
135    ) -> crate::Result<()> {
136        let origin_name = origin.into_name().to_string();
137        let zone = self.find_zone(&origin_name).await?;
138        let fqdn = name.into_fqdn().to_string();
139        let record_type = record.as_type();
140        let existing = self.find_record(zone.id, &fqdn, record_type).await?;
141        let id = existing.id.ok_or_else(|| {
142            Error::Api("LuaDNS record missing id".to_string())
143        })?;
144        let body = build_record(fqdn.as_str(), record, ttl)?;
145
146        self.client
147            .put(format!(
148                "{}/v1/zones/{}/records/{id}",
149                self.endpoint, zone.id
150            ))
151            .with_body(&body)?
152            .send_with_retry::<LuaRecord>(3)
153            .await
154            .map(|_| ())
155    }
156
157    pub(crate) async fn delete(
158        &self,
159        name: impl IntoFqdn<'_>,
160        origin: impl IntoFqdn<'_>,
161        record_type: DnsRecordType,
162    ) -> crate::Result<()> {
163        let origin_name = origin.into_name().to_string();
164        let zone = self.find_zone(&origin_name).await?;
165        let fqdn = name.into_fqdn().to_string();
166        let existing = self.find_record(zone.id, &fqdn, record_type).await?;
167        let id = existing.id.ok_or_else(|| {
168            Error::Api("LuaDNS record missing id".to_string())
169        })?;
170
171        self.client
172            .delete(format!(
173                "{}/v1/zones/{}/records/{id}",
174                self.endpoint, zone.id
175            ))
176            .send_raw()
177            .await
178            .map(|_| ())
179    }
180}
181
182fn ensure_dot(name: String) -> String {
183    if name.ends_with('.') {
184        name
185    } else {
186        format!("{name}.")
187    }
188}
189
190fn build_record<'a>(name: impl IntoFqdn<'a>, record: DnsRecord, ttl: u32) -> crate::Result<LuaRecord> {
191    let rr_type = record.as_type().as_str().to_string();
192    let fqdn = name.into_fqdn().to_string();
193    let content = match record {
194        DnsRecord::A(addr) => addr.to_string(),
195        DnsRecord::AAAA(addr) => addr.to_string(),
196        DnsRecord::CNAME(content) => ensure_dot(content),
197        DnsRecord::NS(content) => ensure_dot(content),
198        DnsRecord::TXT(content) => format!("\"{}\"", content.replace('"', "\\\"")),
199        DnsRecord::MX(mx) => format!("{} {}", mx.priority, ensure_dot(mx.exchange)),
200        DnsRecord::SRV(srv) => format!(
201            "{} {} {} {}",
202            srv.priority,
203            srv.weight,
204            srv.port,
205            ensure_dot(srv.target)
206        ),
207        DnsRecord::TLSA(tlsa) => tlsa.to_string(),
208        DnsRecord::CAA(caa) => caa.to_string(),
209    };
210
211    Ok(LuaRecord {
212        id: None,
213        name: fqdn,
214        rr_type,
215        content,
216        ttl,
217        zone_id: None,
218    })
219}