1use crate::utils::build_caa;
13use crate::{
14 DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord,
15 http::{HttpClient, HttpClientBuilder},
16 utils::strip_origin_from_name,
17};
18use serde::{Deserialize, Serialize};
19use std::{borrow::Cow, time::Duration};
20
21const DEFAULT_API_ENDPOINT: &str = "https://api.linode.com/v4";
22const LIST_PAGE_SIZE: u32 = 500;
23
24#[derive(Clone)]
25pub struct LinodeProvider {
26 client: HttpClient,
27 endpoint: Cow<'static, str>,
28}
29
30#[derive(Deserialize, Debug)]
31struct PagedDomains {
32 data: Vec<Domain>,
33}
34
35#[derive(Deserialize, Debug)]
36struct Domain {
37 id: i64,
38 domain: String,
39}
40
41#[derive(Deserialize, Debug)]
42struct PagedDomainRecords {
43 data: Vec<DomainRecord>,
44 page: u32,
45 pages: u32,
46}
47
48#[derive(Deserialize, Debug, Clone)]
49struct DomainRecord {
50 id: i64,
51 name: String,
52 #[serde(rename = "type")]
53 record_type: String,
54 #[serde(default)]
55 target: String,
56 #[serde(default)]
57 priority: u16,
58 #[serde(default)]
59 weight: u16,
60 #[serde(default)]
61 port: u16,
62 #[serde(default)]
63 service: Option<String>,
64 #[serde(default)]
65 protocol: Option<String>,
66 #[serde(default)]
67 tag: Option<String>,
68}
69
70#[derive(Serialize, Debug)]
71struct DomainRecordRequest<'a> {
72 name: &'a str,
73 #[serde(rename = "type")]
74 record_type: &'static str,
75 target: String,
76 ttl_sec: u32,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 priority: Option<u16>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 weight: Option<u16>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 port: Option<u16>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 service: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 protocol: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 tag: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92struct RecordValue {
93 target: String,
94 priority: Option<u16>,
95 weight: Option<u16>,
96 port: Option<u16>,
97 service: Option<String>,
98 protocol: Option<String>,
99 tag: Option<String>,
100}
101
102impl LinodeProvider {
103 pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
104 let client = HttpClientBuilder::default()
105 .with_header("Authorization", format!("Bearer {}", auth_token.as_ref()))
106 .with_timeout(timeout)
107 .build();
108 Self {
109 client,
110 endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
111 }
112 }
113
114 #[cfg(test)]
115 pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
116 Self {
117 endpoint: endpoint.into(),
118 ..self
119 }
120 }
121
122 pub(crate) async fn set_rrset(
123 &self,
124 name: impl IntoFqdn<'_>,
125 record_type: DnsRecordType,
126 ttl: u32,
127 records: Vec<DnsRecord>,
128 origin: impl IntoFqdn<'_>,
129 ) -> crate::Result<()> {
130 reject_unsupported(record_type)?;
131 let domain = origin.into_name().into_owned();
132 let name = name.into_name().into_owned();
133 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
134 let domain_id = self.obtain_domain_id(&domain).await?;
135 let desired = build_values(record_type, records)?;
136 let existing = self.list_at(domain_id, &subdomain, record_type).await?;
137
138 let mut existing_pool = existing;
139 let mut to_add: Vec<RecordValue> = Vec::new();
140
141 for value in desired {
142 if let Some(idx) = existing_pool
143 .iter()
144 .position(|r| listed_to_value(r) == value)
145 {
146 existing_pool.swap_remove(idx);
147 } else {
148 to_add.push(value);
149 }
150 }
151
152 for entry in existing_pool {
153 self.delete_record(domain_id, entry.id).await?;
154 }
155 for value in to_add {
156 self.create_record(domain_id, &subdomain, record_type, ttl, value)
157 .await?;
158 }
159 Ok(())
160 }
161
162 pub(crate) async fn add_to_rrset(
163 &self,
164 name: impl IntoFqdn<'_>,
165 record_type: DnsRecordType,
166 ttl: u32,
167 records: Vec<DnsRecord>,
168 origin: impl IntoFqdn<'_>,
169 ) -> crate::Result<()> {
170 if records.is_empty() {
171 return Ok(());
172 }
173 reject_unsupported(record_type)?;
174 let domain = origin.into_name().into_owned();
175 let name = name.into_name().into_owned();
176 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
177 let domain_id = self.obtain_domain_id(&domain).await?;
178 let desired = build_values(record_type, records)?;
179 let existing = self.list_at(domain_id, &subdomain, record_type).await?;
180
181 for value in desired {
182 if existing.iter().any(|r| listed_to_value(r) == value) {
183 continue;
184 }
185 self.create_record(domain_id, &subdomain, record_type, ttl, value)
186 .await?;
187 }
188 Ok(())
189 }
190
191 pub(crate) async fn remove_from_rrset(
192 &self,
193 name: impl IntoFqdn<'_>,
194 record_type: DnsRecordType,
195 records: Vec<DnsRecord>,
196 origin: impl IntoFqdn<'_>,
197 ) -> crate::Result<()> {
198 if records.is_empty() {
199 return Ok(());
200 }
201 reject_unsupported(record_type)?;
202 let domain = origin.into_name().into_owned();
203 let name = name.into_name().into_owned();
204 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
205 let domain_id = self.obtain_domain_id(&domain).await?;
206 let to_remove = build_values(record_type, records)?;
207 let existing = self.list_at(domain_id, &subdomain, record_type).await?;
208
209 for value in to_remove {
210 if let Some(entry) = existing.iter().find(|r| listed_to_value(r) == value) {
211 self.delete_record(domain_id, entry.id).await?;
212 }
213 }
214 Ok(())
215 }
216
217 pub(crate) async fn list_rrset(
218 &self,
219 name: impl IntoFqdn<'_>,
220 record_type: DnsRecordType,
221 origin: impl IntoFqdn<'_>,
222 ) -> crate::Result<Vec<DnsRecord>> {
223 reject_unsupported(record_type)?;
224 let domain = origin.into_name().into_owned();
225 let name = name.into_name().into_owned();
226 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
227 let domain_id = self.obtain_domain_id(&domain).await?;
228 let listed = self.list_at(domain_id, &subdomain, record_type).await?;
229 listed
230 .into_iter()
231 .map(|r| value_to_record(record_type, listed_to_value(&r)))
232 .collect()
233 }
234
235 async fn obtain_domain_id(&self, domain: &str) -> crate::Result<i64> {
236 self.client
237 .get(format!("{}/domains", self.endpoint))
238 .with_header("X-Filter", format!(r#"{{"domain":"{}"}}"#, domain))
239 .send_with_retry::<PagedDomains>(3)
240 .await
241 .and_then(|response| {
242 response
243 .data
244 .into_iter()
245 .find(|d| d.domain == domain)
246 .map(|d| d.id)
247 .ok_or_else(|| Error::Api(format!("Linode domain {domain} not found")))
248 })
249 }
250
251 async fn list_at(
252 &self,
253 domain_id: i64,
254 subdomain: &str,
255 record_type: DnsRecordType,
256 ) -> crate::Result<Vec<DomainRecord>> {
257 let wanted_type = record_type.as_str();
258 let mut out: Vec<DomainRecord> = Vec::new();
259 let mut page: u32 = 1;
260 loop {
261 let url = format!(
262 "{}/domains/{}/records?page={}&page_size={}",
263 self.endpoint, domain_id, page, LIST_PAGE_SIZE
264 );
265 let response: PagedDomainRecords = self
266 .client
267 .get(url)
268 .with_header("X-Filter", format!(r#"{{"type":"{}"}}"#, wanted_type))
269 .send_with_retry(3)
270 .await?;
271 let total_pages = response.pages.max(response.page);
272 for r in response.data {
273 if r.record_type == wanted_type && published_name(&r) == subdomain {
274 out.push(r);
275 }
276 }
277 if page >= total_pages {
278 break;
279 }
280 page += 1;
281 }
282 Ok(out)
283 }
284
285 async fn create_record(
286 &self,
287 domain_id: i64,
288 subdomain: &str,
289 record_type: DnsRecordType,
290 ttl: u32,
291 value: RecordValue,
292 ) -> crate::Result<()> {
293 let (rest_name, service, protocol) = split_srv_name(subdomain, &value);
294 let body = build_request(
295 rest_name,
296 record_type.as_str(),
297 ttl,
298 value,
299 service,
300 protocol,
301 );
302 self.client
303 .post(format!("{}/domains/{}/records", self.endpoint, domain_id))
304 .with_body(body)?
305 .send_raw()
306 .await
307 .map(|_| ())
308 }
309
310 async fn delete_record(&self, domain_id: i64, record_id: i64) -> crate::Result<()> {
311 self.client
312 .delete(format!(
313 "{}/domains/{}/records/{}",
314 self.endpoint, domain_id, record_id
315 ))
316 .send_raw()
317 .await
318 .map(|_| ())
319 }
320}
321
322fn reject_unsupported(record_type: DnsRecordType) -> crate::Result<()> {
323 match record_type {
324 DnsRecordType::TLSA => Err(Error::Unsupported(
325 "TLSA records are not supported by Linode".to_string(),
326 )),
327 _ => Ok(()),
328 }
329}
330
331fn build_values(
332 expected_type: DnsRecordType,
333 records: Vec<DnsRecord>,
334) -> crate::Result<Vec<RecordValue>> {
335 let mut out = Vec::with_capacity(records.len());
336 for record in records {
337 if record.as_type() != expected_type {
338 return Err(Error::Api(format!(
339 "RRSet record type mismatch: expected {}, got {}",
340 expected_type.as_str(),
341 record.as_type().as_str(),
342 )));
343 }
344 out.push(record_to_value(record)?);
345 }
346 Ok(out)
347}
348
349fn record_to_value(record: DnsRecord) -> crate::Result<RecordValue> {
350 match record {
351 DnsRecord::A(addr) => Ok(RecordValue {
352 target: addr.to_string(),
353 priority: None,
354 weight: None,
355 port: None,
356 service: None,
357 protocol: None,
358 tag: None,
359 }),
360 DnsRecord::AAAA(addr) => Ok(RecordValue {
361 target: addr.to_string(),
362 priority: None,
363 weight: None,
364 port: None,
365 service: None,
366 protocol: None,
367 tag: None,
368 }),
369 DnsRecord::CNAME(content) => Ok(RecordValue {
370 target: content,
371 priority: None,
372 weight: None,
373 port: None,
374 service: None,
375 protocol: None,
376 tag: None,
377 }),
378 DnsRecord::NS(content) => Ok(RecordValue {
379 target: content,
380 priority: None,
381 weight: None,
382 port: None,
383 service: None,
384 protocol: None,
385 tag: None,
386 }),
387 DnsRecord::MX(mx) => Ok(RecordValue {
388 target: mx.exchange,
389 priority: Some(mx.priority),
390 weight: None,
391 port: None,
392 service: None,
393 protocol: None,
394 tag: None,
395 }),
396 DnsRecord::TXT(content) => Ok(RecordValue {
397 target: content,
398 priority: None,
399 weight: None,
400 port: None,
401 service: None,
402 protocol: None,
403 tag: None,
404 }),
405 DnsRecord::SRV(srv) => Ok(RecordValue {
406 target: srv.target,
407 priority: Some(srv.priority),
408 weight: Some(srv.weight),
409 port: Some(srv.port),
410 service: None,
411 protocol: None,
412 tag: None,
413 }),
414 DnsRecord::TLSA(_) => Err(Error::Unsupported(
415 "TLSA records are not supported by Linode".to_string(),
416 )),
417 DnsRecord::CAA(caa) => {
418 let (_flags, tag, value) = caa.decompose();
419 Ok(RecordValue {
420 target: value,
421 priority: None,
422 weight: None,
423 port: None,
424 service: None,
425 protocol: None,
426 tag: Some(tag),
427 })
428 }
429 }
430}
431
432fn listed_to_value(record: &DomainRecord) -> RecordValue {
433 let is_srv = record.record_type == "SRV";
434 let is_mx = record.record_type == "MX";
435 let is_caa = record.record_type == "CAA";
436 RecordValue {
437 target: record.target.clone(),
438 priority: if is_mx || is_srv {
439 Some(record.priority)
440 } else {
441 None
442 },
443 weight: if is_srv { Some(record.weight) } else { None },
444 port: if is_srv { Some(record.port) } else { None },
445 service: None,
446 protocol: None,
447 tag: if is_caa { record.tag.clone() } else { None },
448 }
449}
450
451fn value_to_record(record_type: DnsRecordType, value: RecordValue) -> crate::Result<DnsRecord> {
452 match record_type {
453 DnsRecordType::A => value
454 .target
455 .parse()
456 .map(DnsRecord::A)
457 .map_err(|e| Error::Parse(format!("invalid A target {}: {e}", value.target))),
458 DnsRecordType::AAAA => value
459 .target
460 .parse()
461 .map(DnsRecord::AAAA)
462 .map_err(|e| Error::Parse(format!("invalid AAAA target {}: {e}", value.target))),
463 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(value.target)),
464 DnsRecordType::NS => Ok(DnsRecord::NS(value.target)),
465 DnsRecordType::MX => Ok(DnsRecord::MX(MXRecord {
466 exchange: value.target,
467 priority: value.priority.unwrap_or_default(),
468 })),
469 DnsRecordType::TXT => Ok(DnsRecord::TXT(value.target)),
470 DnsRecordType::SRV => Ok(DnsRecord::SRV(SRVRecord {
471 target: value.target,
472 priority: value.priority.unwrap_or_default(),
473 weight: value.weight.unwrap_or_default(),
474 port: value.port.unwrap_or_default(),
475 })),
476 DnsRecordType::TLSA => Err(Error::Unsupported(
477 "TLSA records are not supported by Linode".to_string(),
478 )),
479 DnsRecordType::CAA => {
480 let tag = value.tag.unwrap_or_default();
481 build_caa(0, &tag, &value.target).map(DnsRecord::CAA)
482 }
483 }
484}
485
486fn build_request<'a>(
487 name: &'a str,
488 record_type: &'static str,
489 ttl: u32,
490 value: RecordValue,
491 service: Option<String>,
492 protocol: Option<String>,
493) -> DomainRecordRequest<'a> {
494 DomainRecordRequest {
495 name,
496 record_type,
497 target: value.target,
498 ttl_sec: ttl,
499 priority: value.priority,
500 weight: value.weight,
501 port: value.port,
502 service,
503 protocol,
504 tag: value.tag,
505 }
506}
507
508fn split_srv_name<'a>(
509 subdomain: &'a str,
510 value: &RecordValue,
511) -> (&'a str, Option<String>, Option<String>) {
512 if value.port.is_none() && value.weight.is_none() {
513 return (subdomain, None, None);
514 }
515 let mut parts = subdomain.splitn(3, '.');
516 let first = parts.next().unwrap_or("");
517 let second = parts.next().unwrap_or("");
518 let rest = parts.next().unwrap_or("");
519 if first.starts_with('_') && second.starts_with('_') {
520 (rest, Some(first.to_string()), Some(second.to_string()))
521 } else {
522 (subdomain, None, None)
523 }
524}
525
526fn published_name(record: &DomainRecord) -> String {
527 match (record.service.as_deref(), record.protocol.as_deref()) {
528 (Some(service), Some(protocol)) if !service.is_empty() && !protocol.is_empty() => {
529 if record.name.is_empty() {
530 format!("{service}.{protocol}")
531 } else {
532 format!("{service}.{protocol}.{}", record.name)
533 }
534 }
535 _ => record.name.clone(),
536 }
537}