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 self.client
94 .post(format!(
95 "{endpoint}/domains/{domain}/rrsets/",
96 endpoint = self.endpoint,
97 domain = domain
98 ))
99 .with_body(DnsRecordParams {
100 subname: &subdomain,
101 rr_type: &desec_record.record_type,
102 ttl: Some(ttl),
103 records: vec![desec_record.content],
104 })?
105 .send_with_retry::<DesecApiResponse>(3)
106 .await
107 .map(|_| ())
108 }
109
110 pub(crate) async fn update(
111 &self,
112 name: impl IntoFqdn<'_>,
113 record: DnsRecord,
114 ttl: u32,
115 origin: impl IntoFqdn<'_>,
116 ) -> crate::Result<()> {
117 let name = name.into_name().to_ascii_lowercase();
118 let domain = origin.into_name().to_ascii_lowercase();
119 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
120 let ttl = ttl.max(DESEC_MIN_TTL);
121
122 let desec_record = DesecDnsRecordRepresentation::from(record);
123 self.client
124 .put(format!(
125 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
126 endpoint = self.endpoint,
127 domain = &domain,
128 subdomain = &subdomain,
129 rr_type = &desec_record.record_type,
130 ))
131 .with_body(DnsRecordParams {
132 subname: &subdomain,
133 rr_type: desec_record.record_type.as_str(),
134 ttl: Some(ttl),
135 records: vec![desec_record.content],
136 })?
137 .send_with_retry::<DesecApiResponse>(3)
138 .await
139 .map(|_| ())
140 }
141
142 pub(crate) async fn delete(
143 &self,
144 name: impl IntoFqdn<'_>,
145 origin: impl IntoFqdn<'_>,
146 record_type: DnsRecordType,
147 ) -> crate::Result<()> {
148 let name = name.into_name().to_ascii_lowercase();
149 let domain = origin.into_name().to_ascii_lowercase();
150 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
151
152 let rr_type = &record_type.to_string();
153 self.client
154 .delete(format!(
155 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rtype}/",
156 endpoint = self.endpoint,
157 domain = &domain,
158 subdomain = &subdomain,
159 rtype = &rr_type.to_string(),
160 ))
161 .send_with_retry::<DesecEmptyResponse>(3)
162 .await
163 .map(|_| ())
164 }
165}
166
167fn ensure_fqdn(name: String) -> String {
168 if name.ends_with('.') {
169 name
170 } else {
171 format!("{name}.")
172 }
173}
174
175impl From<DnsRecord> for DesecDnsRecordRepresentation {
177 fn from(record: DnsRecord) -> Self {
178 match record {
179 DnsRecord::A(content) => DesecDnsRecordRepresentation {
180 record_type: "A".to_string(),
181 content: content.to_string(),
182 },
183 DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
184 record_type: "AAAA".to_string(),
185 content: content.to_string(),
186 },
187 DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
188 record_type: "CNAME".to_string(),
189 content: ensure_fqdn(content),
190 },
191 DnsRecord::NS(content) => DesecDnsRecordRepresentation {
192 record_type: "NS".to_string(),
193 content: ensure_fqdn(content),
194 },
195 DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
196 record_type: "MX".to_string(),
197 content: format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
198 },
199 DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
200 record_type: "TXT".to_string(),
201 content: format!("\"{content}\""),
202 },
203 DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
204 record_type: "SRV".to_string(),
205 content: format!(
206 "{} {} {} {}",
207 srv.priority,
208 srv.weight,
209 srv.port,
210 ensure_fqdn(srv.target)
211 ),
212 },
213 DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
214 record_type: "TLSA".to_string(),
215 content: tlsa.to_string(),
216 },
217 DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
218 record_type: "CAA".to_string(),
219 content: caa.to_string(),
220 },
221 }
222 }
223}