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