1use crate::{
13 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
14 TLSARecord, TlsaCertUsage, TlsaMatching, TlsaSelector,
15 http::{HttpClient, HttpClientBuilder},
16 utils::strip_origin_from_name,
17};
18use serde::{Deserialize, Serialize};
19use std::time::Duration;
20
21const DEFAULT_ENDPOINT: &str = "https://developers.hostinger.com";
22
23#[derive(Clone)]
24pub struct HostingerProvider {
25 client: HttpClient,
26 endpoint: String,
27}
28
29#[derive(Serialize, Debug)]
30pub struct ZoneRequest {
31 pub overwrite: bool,
32 #[serde(skip_serializing_if = "Vec::is_empty")]
33 pub zone: Vec<RecordSet>,
34}
35
36#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct RecordSet {
38 pub name: String,
39 #[serde(rename = "type")]
40 pub record_type: String,
41 pub ttl: u32,
42 pub records: Vec<RecordValue>,
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone)]
46pub struct RecordValue {
47 pub content: String,
48 #[serde(default, skip_serializing_if = "is_false")]
49 pub is_disabled: bool,
50}
51
52#[derive(Serialize, Debug)]
53pub struct Filters {
54 pub filters: Vec<Filter>,
55}
56
57#[derive(Serialize, Debug)]
58pub struct Filter {
59 pub name: String,
60 #[serde(rename = "type")]
61 pub record_type: String,
62}
63
64fn is_false(value: &bool) -> bool {
65 !*value
66}
67
68impl HostingerProvider {
69 pub(crate) fn new(
70 api_token: impl AsRef<str>,
71 timeout: Option<Duration>,
72 ) -> crate::Result<Self> {
73 let token = api_token.as_ref();
74 if token.is_empty() {
75 return Err(Error::Api("Hostinger API token is empty".to_string()));
76 }
77 let client = HttpClientBuilder::default()
78 .with_header("Authorization", format!("Bearer {token}"))
79 .with_header("Accept", "application/json")
80 .with_timeout(timeout)
81 .build();
82 Ok(Self {
83 client,
84 endpoint: DEFAULT_ENDPOINT.to_string(),
85 })
86 }
87
88 #[cfg(test)]
89 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
90 Self {
91 endpoint: endpoint.as_ref().to_string(),
92 ..self
93 }
94 }
95
96 fn zone_url(&self, domain: &str) -> String {
97 format!("{}/api/dns/v1/zones/{}", self.endpoint, domain)
98 }
99
100 pub(crate) async fn set_rrset(
101 &self,
102 name: impl IntoFqdn<'_>,
103 record_type: DnsRecordType,
104 ttl: u32,
105 records: Vec<DnsRecord>,
106 origin: impl IntoFqdn<'_>,
107 ) -> crate::Result<()> {
108 check_record_types(record_type, &records)?;
109 let name = name.into_name();
110 let domain = origin.into_name();
111 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
112
113 if records.is_empty() {
114 let request = Filters {
115 filters: vec![Filter {
116 name: subdomain,
117 record_type: record_type.as_str().to_string(),
118 }],
119 };
120 return self
121 .client
122 .delete(self.zone_url(&domain))
123 .with_body(request)?
124 .send_with_retry::<serde_json::Value>(3)
125 .await
126 .map(|_| ())
127 .or_else(|err| match err {
128 Error::NotFound => Ok(()),
129 err => Err(err),
130 });
131 }
132
133 let contents = build_contents(record_type, records);
134 let new_rrset = RecordSet {
135 name: subdomain,
136 record_type: record_type.as_str().to_string(),
137 ttl,
138 records: contents
139 .into_iter()
140 .map(|content| RecordValue {
141 content,
142 is_disabled: false,
143 })
144 .collect(),
145 };
146
147 let request = ZoneRequest {
148 overwrite: true,
149 zone: vec![new_rrset],
150 };
151
152 self.client
153 .put(self.zone_url(&domain))
154 .with_body(request)?
155 .send_with_retry::<serde_json::Value>(3)
156 .await
157 .map(|_| ())
158 }
159
160 pub(crate) async fn add_to_rrset(
161 &self,
162 name: impl IntoFqdn<'_>,
163 record_type: DnsRecordType,
164 ttl: u32,
165 records: Vec<DnsRecord>,
166 origin: impl IntoFqdn<'_>,
167 ) -> crate::Result<()> {
168 check_record_types(record_type, &records)?;
169 if records.is_empty() {
170 return Ok(());
171 }
172 let name = name.into_name();
173 let domain = origin.into_name();
174 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
175 let to_add = build_contents(record_type, records);
176 let zone = self.fetch_zone(&domain).await?;
177
178 let mut existing = zone
179 .into_iter()
180 .find(|r| r.name == subdomain && r.record_type == record_type.as_str());
181
182 let mut changed = false;
183 let merged = match existing.as_mut() {
184 Some(rrset) => {
185 let before = rrset.records.len();
186 for content in &to_add {
187 if !rrset.records.iter().any(|r| &r.content == content) {
188 rrset.records.push(RecordValue {
189 content: content.clone(),
190 is_disabled: false,
191 });
192 }
193 }
194 if rrset.records.len() != before {
195 changed = true;
196 }
197 existing.unwrap()
198 }
199 None => {
200 changed = true;
201 RecordSet {
202 name: subdomain,
203 record_type: record_type.as_str().to_string(),
204 ttl,
205 records: to_add
206 .into_iter()
207 .map(|content| RecordValue {
208 content,
209 is_disabled: false,
210 })
211 .collect(),
212 }
213 }
214 };
215
216 if !changed {
217 return Ok(());
218 }
219
220 let request = ZoneRequest {
221 overwrite: true,
222 zone: vec![merged],
223 };
224
225 self.client
226 .put(self.zone_url(&domain))
227 .with_body(request)?
228 .send_with_retry::<serde_json::Value>(3)
229 .await
230 .map(|_| ())
231 }
232
233 pub(crate) async fn remove_from_rrset(
234 &self,
235 name: impl IntoFqdn<'_>,
236 record_type: DnsRecordType,
237 records: Vec<DnsRecord>,
238 origin: impl IntoFqdn<'_>,
239 ) -> crate::Result<()> {
240 check_record_types(record_type, &records)?;
241 if records.is_empty() {
242 return Ok(());
243 }
244 let name = name.into_name();
245 let domain = origin.into_name();
246 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
247 let to_remove = build_contents(record_type, records);
248 let zone = self.fetch_zone(&domain).await?;
249
250 let Some(rrset) = zone
251 .into_iter()
252 .find(|r| r.name == subdomain && r.record_type == record_type.as_str())
253 else {
254 return Ok(());
255 };
256
257 let before = rrset.records.len();
258 let filtered: Vec<RecordValue> = rrset
259 .records
260 .into_iter()
261 .filter(|r| !to_remove.iter().any(|c| c == &r.content))
262 .collect();
263
264 if filtered.len() == before {
265 return Ok(());
266 }
267
268 if filtered.is_empty() {
269 let request = Filters {
270 filters: vec![Filter {
271 name: subdomain,
272 record_type: record_type.as_str().to_string(),
273 }],
274 };
275 return self
276 .client
277 .delete(self.zone_url(&domain))
278 .with_body(request)?
279 .send_with_retry::<serde_json::Value>(3)
280 .await
281 .map(|_| ())
282 .or_else(|err| match err {
283 Error::NotFound => Ok(()),
284 err => Err(err),
285 });
286 }
287
288 let updated = RecordSet {
289 name: rrset.name,
290 record_type: rrset.record_type,
291 ttl: rrset.ttl,
292 records: filtered,
293 };
294 let request = ZoneRequest {
295 overwrite: true,
296 zone: vec![updated],
297 };
298
299 self.client
300 .put(self.zone_url(&domain))
301 .with_body(request)?
302 .send_with_retry::<serde_json::Value>(3)
303 .await
304 .map(|_| ())
305 }
306
307 pub(crate) async fn list_rrset(
308 &self,
309 name: impl IntoFqdn<'_>,
310 record_type: DnsRecordType,
311 origin: impl IntoFqdn<'_>,
312 ) -> crate::Result<Vec<DnsRecord>> {
313 let name = name.into_name();
314 let domain = origin.into_name();
315 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
316
317 let rrset = self
318 .fetch_record_set(&domain, &subdomain, record_type)
319 .await?;
320 match rrset {
321 None => Ok(Vec::new()),
322 Some(rrset) => rrset
323 .records
324 .into_iter()
325 .map(|r| parse_record(record_type, &r.content))
326 .collect(),
327 }
328 }
329
330 async fn fetch_zone(&self, domain: &str) -> crate::Result<Vec<RecordSet>> {
331 let response = self.client.get(self.zone_url(domain)).send_raw().await?;
332 if response.is_empty() {
333 return Ok(Vec::new());
334 }
335 serde_json::from_str(&response)
336 .map_err(|err| Error::Serialize(format!("Failed to deserialize Hostinger zone: {err}")))
337 }
338
339 async fn fetch_record_set(
340 &self,
341 domain: &str,
342 subdomain: &str,
343 record_type: DnsRecordType,
344 ) -> crate::Result<Option<RecordSet>> {
345 let zone = self.fetch_zone(domain).await?;
346 Ok(zone
347 .into_iter()
348 .find(|r| r.name == subdomain && r.record_type == record_type.as_str()))
349 }
350}
351
352fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
353 for r in records {
354 if r.as_type() != expected {
355 return Err(Error::Api(format!(
356 "RRSet record type mismatch: expected {}, got {}",
357 expected.as_str(),
358 r.as_type().as_str(),
359 )));
360 }
361 }
362 Ok(())
363}
364
365fn build_contents(_expected_type: DnsRecordType, records: Vec<DnsRecord>) -> Vec<String> {
366 records.iter().map(encode_record).collect()
367}
368
369fn ensure_fqdn(name: &str) -> String {
370 if name.ends_with('.') {
371 name.to_string()
372 } else {
373 format!("{name}.")
374 }
375}
376
377fn encode_record(record: &DnsRecord) -> String {
378 match record {
379 DnsRecord::A(ip) => ip.to_string(),
380 DnsRecord::AAAA(ip) => ip.to_string(),
381 DnsRecord::CNAME(value) => ensure_fqdn(value),
382 DnsRecord::NS(value) => ensure_fqdn(value),
383 DnsRecord::MX(mx) => format!("{} {}", mx.priority, ensure_fqdn(&mx.exchange)),
384 DnsRecord::TXT(value) => value.clone(),
385 DnsRecord::SRV(srv) => format!(
386 "{} {} {} {}",
387 srv.priority,
388 srv.weight,
389 srv.port,
390 ensure_fqdn(&srv.target)
391 ),
392 DnsRecord::TLSA(tlsa) => tlsa.to_string(),
393 DnsRecord::CAA(caa) => caa.to_string(),
394 }
395}
396
397fn strip_trailing_dot(s: &str) -> &str {
398 s.strip_suffix('.').unwrap_or(s)
399}
400
401fn parse_record(record_type: DnsRecordType, content: &str) -> crate::Result<DnsRecord> {
402 match record_type {
403 DnsRecordType::A => content
404 .parse()
405 .map(DnsRecord::A)
406 .map_err(|e| Error::Parse(format!("invalid A record: {e}"))),
407 DnsRecordType::AAAA => content
408 .parse()
409 .map(DnsRecord::AAAA)
410 .map_err(|e| Error::Parse(format!("invalid AAAA record: {e}"))),
411 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(strip_trailing_dot(content).to_string())),
412 DnsRecordType::NS => Ok(DnsRecord::NS(strip_trailing_dot(content).to_string())),
413 DnsRecordType::MX => parse_mx(content),
414 DnsRecordType::TXT => Ok(DnsRecord::TXT(unquote_txt(content))),
415 DnsRecordType::SRV => parse_srv(content),
416 DnsRecordType::TLSA => parse_tlsa(content),
417 DnsRecordType::CAA => parse_caa(content),
418 }
419}
420
421fn unquote_txt(content: &str) -> String {
422 let trimmed = content
423 .strip_prefix('"')
424 .and_then(|s| s.strip_suffix('"'))
425 .unwrap_or(content);
426 trimmed.replace("\\\"", "\"")
427}
428
429fn parse_mx(content: &str) -> crate::Result<DnsRecord> {
430 let (prio, exchange) = content
431 .split_once(' ')
432 .ok_or_else(|| Error::Parse(format!("invalid MX record: {content}")))?;
433 let priority: u16 = prio
434 .parse()
435 .map_err(|e| Error::Parse(format!("invalid MX priority {prio}: {e}")))?;
436 Ok(DnsRecord::MX(MXRecord {
437 priority,
438 exchange: strip_trailing_dot(exchange.trim()).to_string(),
439 }))
440}
441
442fn parse_srv(content: &str) -> crate::Result<DnsRecord> {
443 let mut parts = content.split_whitespace();
444 let priority: u16 = parts
445 .next()
446 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?
447 .parse()
448 .map_err(|e| Error::Parse(format!("invalid SRV priority: {e}")))?;
449 let weight: u16 = parts
450 .next()
451 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?
452 .parse()
453 .map_err(|e| Error::Parse(format!("invalid SRV weight: {e}")))?;
454 let port: u16 = parts
455 .next()
456 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?
457 .parse()
458 .map_err(|e| Error::Parse(format!("invalid SRV port: {e}")))?;
459 let target = parts
460 .next()
461 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?;
462 Ok(DnsRecord::SRV(SRVRecord {
463 priority,
464 weight,
465 port,
466 target: strip_trailing_dot(target).to_string(),
467 }))
468}
469
470fn parse_tlsa(content: &str) -> crate::Result<DnsRecord> {
471 let mut parts = content.split_whitespace();
472 let usage: u8 = parts
473 .next()
474 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {content}")))?
475 .parse()
476 .map_err(|e| Error::Parse(format!("invalid TLSA usage: {e}")))?;
477 let selector: u8 = parts
478 .next()
479 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {content}")))?
480 .parse()
481 .map_err(|e| Error::Parse(format!("invalid TLSA selector: {e}")))?;
482 let matching: u8 = parts
483 .next()
484 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {content}")))?
485 .parse()
486 .map_err(|e| Error::Parse(format!("invalid TLSA matching: {e}")))?;
487 let hex: String = parts.collect::<Vec<_>>().join("");
488 Ok(DnsRecord::TLSA(TLSARecord {
489 cert_usage: tlsa_cert_usage_from_u8(usage)?,
490 selector: tlsa_selector_from_u8(selector)?,
491 matching: tlsa_matching_from_u8(matching)?,
492 cert_data: decode_hex(&hex)?,
493 }))
494}
495
496fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
497 Ok(match value {
498 0 => TlsaCertUsage::PkixTa,
499 1 => TlsaCertUsage::PkixEe,
500 2 => TlsaCertUsage::DaneTa,
501 3 => TlsaCertUsage::DaneEe,
502 255 => TlsaCertUsage::Private,
503 _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {value}"))),
504 })
505}
506
507fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
508 Ok(match value {
509 0 => TlsaSelector::Full,
510 1 => TlsaSelector::Spki,
511 255 => TlsaSelector::Private,
512 _ => return Err(Error::Parse(format!("unknown TLSA selector: {value}"))),
513 })
514}
515
516fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
517 Ok(match value {
518 0 => TlsaMatching::Raw,
519 1 => TlsaMatching::Sha256,
520 2 => TlsaMatching::Sha512,
521 255 => TlsaMatching::Private,
522 _ => return Err(Error::Parse(format!("unknown TLSA matching: {value}"))),
523 })
524}
525
526fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
527 if !hex.len().is_multiple_of(2) {
528 return Err(Error::Parse(format!("invalid hex string: {hex}")));
529 }
530 (0..hex.len())
531 .step_by(2)
532 .map(|i| {
533 u8::from_str_radix(&hex[i..i + 2], 16)
534 .map_err(|e| Error::Parse(format!("invalid hex byte: {e}")))
535 })
536 .collect()
537}
538
539fn parse_caa(content: &str) -> crate::Result<DnsRecord> {
540 let mut parts = content.splitn(3, ' ');
541 let flags: u8 = parts
542 .next()
543 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
544 .parse()
545 .map_err(|e| Error::Parse(format!("invalid CAA flags: {e}")))?;
546 let tag = parts
547 .next()
548 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
549 .to_string();
550 let raw_value = parts
551 .next()
552 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?;
553 let value = raw_value
554 .strip_prefix('"')
555 .and_then(|s| s.strip_suffix('"'))
556 .unwrap_or(raw_value)
557 .to_string();
558
559 let issuer_critical = flags & 0x80 != 0;
560 match tag.as_str() {
561 "issue" => {
562 let (name, options) = parse_caa_value(&value);
563 Ok(DnsRecord::CAA(CAARecord::Issue {
564 issuer_critical,
565 name,
566 options,
567 }))
568 }
569 "issuewild" => {
570 let (name, options) = parse_caa_value(&value);
571 Ok(DnsRecord::CAA(CAARecord::IssueWild {
572 issuer_critical,
573 name,
574 options,
575 }))
576 }
577 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
578 issuer_critical,
579 url: value,
580 })),
581 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
582 }
583}
584
585fn parse_caa_value(value: &str) -> (Option<String>, Vec<KeyValue>) {
586 let mut parts = value.split(';').map(str::trim);
587 let name_part = parts.next().unwrap_or("").trim().to_string();
588 let name = if name_part.is_empty() {
589 None
590 } else {
591 Some(name_part)
592 };
593 let options = parts
594 .filter(|p| !p.is_empty())
595 .map(|p| match p.split_once('=') {
596 Some((k, v)) => KeyValue {
597 key: k.trim().to_string(),
598 value: v.trim().to_string(),
599 },
600 None => KeyValue {
601 key: p.trim().to_string(),
602 value: String::new(),
603 },
604 })
605 .collect();
606 (name, options)
607}