1use crate::{
13 DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
14 utils::strip_origin_from_name,
15};
16use serde::{Deserialize, Serialize};
17use std::{borrow::Cow, time::Duration};
18
19const DEFAULT_API_ENDPOINT: &str = "https://api.linode.com/v4";
20
21#[derive(Clone)]
22pub struct LinodeProvider {
23 client: HttpClientBuilder,
24 endpoint: Cow<'static, str>,
25}
26
27#[derive(Deserialize, Debug)]
28struct PagedDomains {
29 data: Vec<Domain>,
30}
31
32#[derive(Deserialize, Debug)]
33struct Domain {
34 id: i64,
35 domain: String,
36}
37
38#[derive(Deserialize, Debug)]
39struct PagedDomainRecords {
40 data: Vec<DomainRecord>,
41}
42
43#[derive(Deserialize, Debug)]
44struct DomainRecord {
45 id: i64,
46 name: String,
47 #[serde(rename = "type")]
48 record_type: String,
49}
50
51#[derive(Serialize, Debug)]
52struct DomainRecordRequest<'a> {
53 name: &'a str,
54 #[serde(rename = "type")]
55 record_type: &'static str,
56 target: String,
57 ttl_sec: u32,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 priority: Option<u16>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 weight: Option<u16>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 port: Option<u16>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 tag: Option<String>,
66}
67
68impl LinodeProvider {
69 pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
70 let client = HttpClientBuilder::default()
71 .with_header("Authorization", format!("Bearer {}", auth_token.as_ref()))
72 .with_timeout(timeout);
73 Self {
74 client,
75 endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
76 }
77 }
78
79 #[cfg(test)]
80 pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
81 Self {
82 endpoint: endpoint.into(),
83 ..self
84 }
85 }
86
87 pub(crate) async fn create(
88 &self,
89 name: impl IntoFqdn<'_>,
90 record: DnsRecord,
91 ttl: u32,
92 origin: impl IntoFqdn<'_>,
93 ) -> crate::Result<()> {
94 let domain = origin.into_name();
95 let name = name.into_name();
96 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
97 let domain_id = self.obtain_domain_id(&domain).await?;
98 let body = build_request(&subdomain, record, ttl)?;
99
100 self.client
101 .post(format!("{}/domains/{}/records", self.endpoint, domain_id))
102 .with_body(body)?
103 .send_raw()
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 domain = origin.into_name();
116 let name = name.into_name();
117 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
118 let domain_id = self.obtain_domain_id(&domain).await?;
119 let record_id = self
120 .obtain_record_id(domain_id, &subdomain, record.as_type())
121 .await?;
122 let body = build_request(&subdomain, record, ttl)?;
123
124 self.client
125 .put(format!(
126 "{}/domains/{}/records/{}",
127 self.endpoint, domain_id, record_id
128 ))
129 .with_body(body)?
130 .send_raw()
131 .await
132 .map(|_| ())
133 }
134
135 pub(crate) async fn delete(
136 &self,
137 name: impl IntoFqdn<'_>,
138 origin: impl IntoFqdn<'_>,
139 record_type: DnsRecordType,
140 ) -> crate::Result<()> {
141 let domain = origin.into_name();
142 let name = name.into_name();
143 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
144 let domain_id = self.obtain_domain_id(&domain).await?;
145 let record_id = self
146 .obtain_record_id(domain_id, &subdomain, record_type)
147 .await?;
148
149 self.client
150 .delete(format!(
151 "{}/domains/{}/records/{}",
152 self.endpoint, domain_id, record_id
153 ))
154 .send_raw()
155 .await
156 .map(|_| ())
157 }
158
159 async fn obtain_domain_id(&self, domain: &str) -> crate::Result<i64> {
160 self.client
161 .get(format!("{}/domains", self.endpoint))
162 .with_header("X-Filter", format!(r#"{{"domain":"{}"}}"#, domain))
163 .send_with_retry::<PagedDomains>(3)
164 .await
165 .and_then(|response| {
166 response
167 .data
168 .into_iter()
169 .find(|d| d.domain == domain)
170 .map(|d| d.id)
171 .ok_or_else(|| Error::Api(format!("Linode domain {domain} not found")))
172 })
173 }
174
175 async fn obtain_record_id(
176 &self,
177 domain_id: i64,
178 subdomain: &str,
179 record_type: DnsRecordType,
180 ) -> crate::Result<i64> {
181 let wanted_type = record_type.as_str();
182 self.client
183 .get(format!(
184 "{}/domains/{}/records",
185 self.endpoint, domain_id
186 ))
187 .send_with_retry::<PagedDomainRecords>(3)
188 .await
189 .and_then(|response| {
190 response
191 .data
192 .into_iter()
193 .find(|r| r.name == subdomain && r.record_type == wanted_type)
194 .map(|r| r.id)
195 .ok_or_else(|| {
196 Error::Api(format!(
197 "DNS Record {subdomain} of type {wanted_type} not found"
198 ))
199 })
200 })
201 }
202}
203
204fn build_request<'a>(
205 subdomain: &'a str,
206 record: DnsRecord,
207 ttl: u32,
208) -> crate::Result<DomainRecordRequest<'a>> {
209 let record_type = record.as_type().as_str();
210 match record {
211 DnsRecord::A(addr) => Ok(DomainRecordRequest {
212 name: subdomain,
213 record_type,
214 target: addr.to_string(),
215 ttl_sec: ttl,
216 priority: None,
217 weight: None,
218 port: None,
219 tag: None,
220 }),
221 DnsRecord::AAAA(addr) => Ok(DomainRecordRequest {
222 name: subdomain,
223 record_type,
224 target: addr.to_string(),
225 ttl_sec: ttl,
226 priority: None,
227 weight: None,
228 port: None,
229 tag: None,
230 }),
231 DnsRecord::CNAME(content) => Ok(DomainRecordRequest {
232 name: subdomain,
233 record_type,
234 target: content,
235 ttl_sec: ttl,
236 priority: None,
237 weight: None,
238 port: None,
239 tag: None,
240 }),
241 DnsRecord::NS(content) => Ok(DomainRecordRequest {
242 name: subdomain,
243 record_type,
244 target: content,
245 ttl_sec: ttl,
246 priority: None,
247 weight: None,
248 port: None,
249 tag: None,
250 }),
251 DnsRecord::MX(mx) => Ok(DomainRecordRequest {
252 name: subdomain,
253 record_type,
254 target: mx.exchange,
255 ttl_sec: ttl,
256 priority: Some(mx.priority),
257 weight: None,
258 port: None,
259 tag: None,
260 }),
261 DnsRecord::TXT(content) => Ok(DomainRecordRequest {
262 name: subdomain,
263 record_type,
264 target: content,
265 ttl_sec: ttl,
266 priority: None,
267 weight: None,
268 port: None,
269 tag: None,
270 }),
271 DnsRecord::SRV(srv) => Ok(DomainRecordRequest {
272 name: subdomain,
273 record_type,
274 target: srv.target,
275 ttl_sec: ttl,
276 priority: Some(srv.priority),
277 weight: Some(srv.weight),
278 port: Some(srv.port),
279 tag: None,
280 }),
281 DnsRecord::TLSA(_) => Err(Error::Api(
282 "TLSA records are not supported by Linode".to_string(),
283 )),
284 DnsRecord::CAA(caa) => {
285 let (_flags, tag, value) = caa.decompose();
286 Ok(DomainRecordRequest {
287 name: subdomain,
288 record_type,
289 target: value,
290 ttl_sec: ttl,
291 priority: None,
292 weight: None,
293 port: None,
294 tag: Some(tag),
295 })
296 }
297 }
298}