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