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