1use crate::{
13 DnsRecord, DnsRecordType, IntoFqdn, http::HttpClientBuilder, utils::strip_origin_from_name,
14};
15use serde::{Deserialize, Serialize};
16use std::time::Duration;
17
18pub struct DesecDnsRecordRepresentation {
19 pub record_type: String,
20 pub content: String,
21}
22
23#[derive(Clone)]
24pub struct DesecProvider {
25 client: HttpClientBuilder,
26 endpoint: String,
27}
28
29#[derive(Serialize, Clone, Debug)]
31pub struct DnsRecordParams<'a> {
32 pub subname: &'a str,
33 #[serde(rename = "type")]
34 pub rr_type: &'a str,
35 pub ttl: Option<u32>,
36 pub records: Vec<String>,
37}
38
39#[derive(Deserialize, Debug)]
41pub struct DesecApiResponse {
42 pub created: String,
43 pub domain: String,
44 pub subname: String,
45 pub name: String,
46 pub records: Vec<String>,
47 pub ttl: u32,
48 #[serde(rename = "type")]
49 pub record_type: String,
50 pub touched: String,
51}
52
53#[derive(Deserialize)]
54struct DesecEmptyResponse {}
55
56const DEFAULT_API_ENDPOINT: &str = "https://desec.io/api/v1";
57
58const DESEC_MIN_TTL: u32 = 3600;
59
60impl DesecProvider {
61 pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
62 let client = HttpClientBuilder::default()
63 .with_header("Authorization", format!("Token {}", auth_token.as_ref()))
64 .with_timeout(timeout);
65
66 Self {
67 client,
68 endpoint: DEFAULT_API_ENDPOINT.to_string(),
69 }
70 }
71
72 #[cfg(test)]
73 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
74 Self {
75 endpoint: endpoint.as_ref().to_string(),
76 ..self
77 }
78 }
79
80 pub(crate) async fn create(
81 &self,
82 name: impl IntoFqdn<'_>,
83 record: DnsRecord,
84 ttl: u32,
85 origin: impl IntoFqdn<'_>,
86 ) -> crate::Result<()> {
87 let name = name.into_name().to_ascii_lowercase();
88 let domain = origin.into_name().to_ascii_lowercase();
89 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
90 let ttl = ttl.max(DESEC_MIN_TTL);
91
92 let desec_record = DesecDnsRecordRepresentation::from(record);
93
94 let rrset_url = format!(
95 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
96 endpoint = self.endpoint,
97 domain = &domain,
98 subdomain = &subdomain,
99 rr_type = &desec_record.record_type,
100 );
101
102 let (mut records, existed) = match self
103 .client
104 .get(rrset_url.clone())
105 .send_with_retry::<DesecApiResponse>(3)
106 .await
107 {
108 Ok(existing) => (existing.records, true),
109 Err(crate::Error::NotFound) => (Vec::new(), false),
110 Err(err) => return Err(err),
111 };
112
113 if !records.iter().any(|r| r == &desec_record.content) {
114 records.push(desec_record.content);
115 }
116
117 let params = DnsRecordParams {
118 subname: &subdomain,
119 rr_type: &desec_record.record_type,
120 ttl: Some(ttl),
121 records,
122 };
123
124 if existed {
125 self.client.put(rrset_url)
126 } else {
127 self.client.post(format!(
128 "{endpoint}/domains/{domain}/rrsets/",
129 endpoint = self.endpoint,
130 domain = domain
131 ))
132 }
133 .with_body(params)?
134 .send_with_retry::<DesecApiResponse>(3)
135 .await
136 .map(|_| ())
137 }
138
139 pub(crate) async fn update(
140 &self,
141 name: impl IntoFqdn<'_>,
142 record: DnsRecord,
143 ttl: u32,
144 origin: impl IntoFqdn<'_>,
145 ) -> crate::Result<()> {
146 let name = name.into_name().to_ascii_lowercase();
147 let domain = origin.into_name().to_ascii_lowercase();
148 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
149 let ttl = ttl.max(DESEC_MIN_TTL);
150
151 let desec_record = DesecDnsRecordRepresentation::from(record);
152 self.client
153 .put(format!(
154 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
155 endpoint = self.endpoint,
156 domain = &domain,
157 subdomain = &subdomain,
158 rr_type = &desec_record.record_type,
159 ))
160 .with_body(DnsRecordParams {
161 subname: &subdomain,
162 rr_type: desec_record.record_type.as_str(),
163 ttl: Some(ttl),
164 records: vec![desec_record.content],
165 })?
166 .send_with_retry::<DesecApiResponse>(3)
167 .await
168 .map(|_| ())
169 }
170
171 pub(crate) async fn delete(
172 &self,
173 name: impl IntoFqdn<'_>,
174 origin: impl IntoFqdn<'_>,
175 record_type: DnsRecordType,
176 ) -> crate::Result<()> {
177 let name = name.into_name().to_ascii_lowercase();
178 let domain = origin.into_name().to_ascii_lowercase();
179 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
180
181 let rr_type = &record_type.to_string();
182 self.client
183 .delete(format!(
184 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rtype}/",
185 endpoint = self.endpoint,
186 domain = &domain,
187 subdomain = &subdomain,
188 rtype = &rr_type.to_string(),
189 ))
190 .send_with_retry::<DesecEmptyResponse>(3)
191 .await
192 .map(|_| ())
193 .or_else(|err| match err {
194 crate::Error::NotFound => Ok(()),
195 err => Err(err),
196 })
197 }
198}
199
200fn ensure_fqdn(name: String) -> String {
201 if name.ends_with('.') {
202 name
203 } else {
204 format!("{name}.")
205 }
206}
207
208impl From<DnsRecord> for DesecDnsRecordRepresentation {
210 fn from(record: DnsRecord) -> Self {
211 match record {
212 DnsRecord::A(content) => DesecDnsRecordRepresentation {
213 record_type: "A".to_string(),
214 content: content.to_string(),
215 },
216 DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
217 record_type: "AAAA".to_string(),
218 content: content.to_string(),
219 },
220 DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
221 record_type: "CNAME".to_string(),
222 content: ensure_fqdn(content),
223 },
224 DnsRecord::NS(content) => DesecDnsRecordRepresentation {
225 record_type: "NS".to_string(),
226 content: ensure_fqdn(content),
227 },
228 DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
229 record_type: "MX".to_string(),
230 content: format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
231 },
232 DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
233 record_type: "TXT".to_string(),
234 content: format!("\"{content}\""),
235 },
236 DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
237 record_type: "SRV".to_string(),
238 content: format!(
239 "{} {} {} {}",
240 srv.priority,
241 srv.weight,
242 srv.port,
243 ensure_fqdn(srv.target)
244 ),
245 },
246 DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
247 record_type: "TLSA".to_string(),
248 content: tlsa.to_string(),
249 },
250 DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
251 record_type: "CAA".to_string(),
252 content: caa.to_string(),
253 },
254 }
255 }
256}