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