1use crate::utils::split_caa_value;
13use crate::utils::strip_trailing_dot;
14use crate::utils::unquote_txt;
15use crate::utils::{parse_mx, parse_srv, parse_tlsa};
16use crate::{
17 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn,
18 http::{HttpClient, HttpClientBuilder},
19 utils::strip_origin_from_name,
20};
21use serde::{Deserialize, Serialize};
22use std::time::Duration;
23
24const DEFAULT_ENDPOINT: &str = "https://developers.hostinger.com";
25
26#[derive(Clone)]
27pub struct HostingerProvider {
28 client: HttpClient,
29 endpoint: String,
30}
31
32#[derive(Serialize, Debug)]
33pub struct ZoneRequest {
34 pub overwrite: bool,
35 #[serde(skip_serializing_if = "Vec::is_empty")]
36 pub zone: Vec<RecordSet>,
37}
38
39#[derive(Serialize, Deserialize, Debug, Clone)]
40pub struct RecordSet {
41 pub name: String,
42 #[serde(rename = "type")]
43 pub record_type: String,
44 pub ttl: u32,
45 pub records: Vec<RecordValue>,
46}
47
48#[derive(Serialize, Deserialize, Debug, Clone)]
49pub struct RecordValue {
50 pub content: String,
51 #[serde(default, skip_serializing_if = "is_false")]
52 pub is_disabled: bool,
53}
54
55#[derive(Serialize, Debug)]
56pub struct Filters {
57 pub filters: Vec<Filter>,
58}
59
60#[derive(Serialize, Debug)]
61pub struct Filter {
62 pub name: String,
63 #[serde(rename = "type")]
64 pub record_type: String,
65}
66
67fn is_false(value: &bool) -> bool {
68 !*value
69}
70
71impl HostingerProvider {
72 pub(crate) fn new(
73 api_token: impl AsRef<str>,
74 timeout: Option<Duration>,
75 ) -> crate::Result<Self> {
76 let token = api_token.as_ref();
77 if token.is_empty() {
78 return Err(Error::Api("Hostinger API token is empty".to_string()));
79 }
80 let client = HttpClientBuilder::default()
81 .with_header("Authorization", format!("Bearer {token}"))
82 .with_header("Accept", "application/json")
83 .with_timeout(timeout)
84 .build();
85 Ok(Self {
86 client,
87 endpoint: DEFAULT_ENDPOINT.to_string(),
88 })
89 }
90
91 #[cfg(test)]
92 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
93 Self {
94 endpoint: endpoint.as_ref().to_string(),
95 ..self
96 }
97 }
98
99 fn zone_url(&self, domain: &str) -> String {
100 format!("{}/api/dns/v1/zones/{}", self.endpoint, domain)
101 }
102
103 pub(crate) async fn set_rrset(
104 &self,
105 name: impl IntoFqdn<'_>,
106 record_type: DnsRecordType,
107 ttl: u32,
108 records: Vec<DnsRecord>,
109 origin: impl IntoFqdn<'_>,
110 ) -> crate::Result<()> {
111 check_record_types(record_type, &records)?;
112 let name = name.into_name();
113 let domain = origin.into_name();
114 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
115
116 if records.is_empty() {
117 let request = Filters {
118 filters: vec![Filter {
119 name: subdomain,
120 record_type: record_type.as_str().to_string(),
121 }],
122 };
123 return self
124 .client
125 .delete(self.zone_url(&domain))
126 .with_body(request)?
127 .send_with_retry::<serde_json::Value>(3)
128 .await
129 .map(|_| ())
130 .or_else(|err| match err {
131 Error::NotFound => Ok(()),
132 err => Err(err),
133 });
134 }
135
136 let contents = build_contents(record_type, records);
137 let new_rrset = RecordSet {
138 name: subdomain,
139 record_type: record_type.as_str().to_string(),
140 ttl,
141 records: contents
142 .into_iter()
143 .map(|content| RecordValue {
144 content,
145 is_disabled: false,
146 })
147 .collect(),
148 };
149
150 let request = ZoneRequest {
151 overwrite: true,
152 zone: vec![new_rrset],
153 };
154
155 self.client
156 .put(self.zone_url(&domain))
157 .with_body(request)?
158 .send_with_retry::<serde_json::Value>(3)
159 .await
160 .map(|_| ())
161 }
162
163 pub(crate) async fn add_to_rrset(
164 &self,
165 name: impl IntoFqdn<'_>,
166 record_type: DnsRecordType,
167 ttl: u32,
168 records: Vec<DnsRecord>,
169 origin: impl IntoFqdn<'_>,
170 ) -> crate::Result<()> {
171 check_record_types(record_type, &records)?;
172 if records.is_empty() {
173 return Ok(());
174 }
175 let name = name.into_name();
176 let domain = origin.into_name();
177 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
178 let to_add = build_contents(record_type, records);
179 let zone = self.fetch_zone(&domain).await?;
180
181 let mut existing = zone
182 .into_iter()
183 .find(|r| r.name == subdomain && r.record_type == record_type.as_str());
184
185 let mut changed = false;
186 let merged = match existing.as_mut() {
187 Some(rrset) => {
188 let before = rrset.records.len();
189 for content in &to_add {
190 if !rrset.records.iter().any(|r| &r.content == content) {
191 rrset.records.push(RecordValue {
192 content: content.clone(),
193 is_disabled: false,
194 });
195 }
196 }
197 if rrset.records.len() != before {
198 changed = true;
199 }
200 existing.unwrap()
201 }
202 None => {
203 changed = true;
204 RecordSet {
205 name: subdomain,
206 record_type: record_type.as_str().to_string(),
207 ttl,
208 records: to_add
209 .into_iter()
210 .map(|content| RecordValue {
211 content,
212 is_disabled: false,
213 })
214 .collect(),
215 }
216 }
217 };
218
219 if !changed {
220 return Ok(());
221 }
222
223 let request = ZoneRequest {
224 overwrite: true,
225 zone: vec![merged],
226 };
227
228 self.client
229 .put(self.zone_url(&domain))
230 .with_body(request)?
231 .send_with_retry::<serde_json::Value>(3)
232 .await
233 .map(|_| ())
234 }
235
236 pub(crate) async fn remove_from_rrset(
237 &self,
238 name: impl IntoFqdn<'_>,
239 record_type: DnsRecordType,
240 records: Vec<DnsRecord>,
241 origin: impl IntoFqdn<'_>,
242 ) -> crate::Result<()> {
243 check_record_types(record_type, &records)?;
244 if records.is_empty() {
245 return Ok(());
246 }
247 let name = name.into_name();
248 let domain = origin.into_name();
249 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
250 let to_remove = build_contents(record_type, records);
251 let zone = self.fetch_zone(&domain).await?;
252
253 let Some(rrset) = zone
254 .into_iter()
255 .find(|r| r.name == subdomain && r.record_type == record_type.as_str())
256 else {
257 return Ok(());
258 };
259
260 let before = rrset.records.len();
261 let filtered: Vec<RecordValue> = rrset
262 .records
263 .into_iter()
264 .filter(|r| !to_remove.iter().any(|c| c == &r.content))
265 .collect();
266
267 if filtered.len() == before {
268 return Ok(());
269 }
270
271 if filtered.is_empty() {
272 let request = Filters {
273 filters: vec![Filter {
274 name: subdomain,
275 record_type: record_type.as_str().to_string(),
276 }],
277 };
278 return self
279 .client
280 .delete(self.zone_url(&domain))
281 .with_body(request)?
282 .send_with_retry::<serde_json::Value>(3)
283 .await
284 .map(|_| ())
285 .or_else(|err| match err {
286 Error::NotFound => Ok(()),
287 err => Err(err),
288 });
289 }
290
291 let updated = RecordSet {
292 name: rrset.name,
293 record_type: rrset.record_type,
294 ttl: rrset.ttl,
295 records: filtered,
296 };
297 let request = ZoneRequest {
298 overwrite: true,
299 zone: vec![updated],
300 };
301
302 self.client
303 .put(self.zone_url(&domain))
304 .with_body(request)?
305 .send_with_retry::<serde_json::Value>(3)
306 .await
307 .map(|_| ())
308 }
309
310 pub(crate) async fn list_rrset(
311 &self,
312 name: impl IntoFqdn<'_>,
313 record_type: DnsRecordType,
314 origin: impl IntoFqdn<'_>,
315 ) -> crate::Result<Vec<DnsRecord>> {
316 let name = name.into_name();
317 let domain = origin.into_name();
318 let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
319
320 let rrset = self
321 .fetch_record_set(&domain, &subdomain, record_type)
322 .await?;
323 match rrset {
324 None => Ok(Vec::new()),
325 Some(rrset) => rrset
326 .records
327 .into_iter()
328 .map(|r| parse_record(record_type, &r.content))
329 .collect(),
330 }
331 }
332
333 async fn fetch_zone(&self, domain: &str) -> crate::Result<Vec<RecordSet>> {
334 let response = self.client.get(self.zone_url(domain)).send_raw().await?;
335 if response.is_empty() {
336 return Ok(Vec::new());
337 }
338 serde_json::from_str(&response)
339 .map_err(|err| Error::Serialize(format!("Failed to deserialize Hostinger zone: {err}")))
340 }
341
342 async fn fetch_record_set(
343 &self,
344 domain: &str,
345 subdomain: &str,
346 record_type: DnsRecordType,
347 ) -> crate::Result<Option<RecordSet>> {
348 let zone = self.fetch_zone(domain).await?;
349 Ok(zone
350 .into_iter()
351 .find(|r| r.name == subdomain && r.record_type == record_type.as_str()))
352 }
353}
354
355fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
356 for r in records {
357 if r.as_type() != expected {
358 return Err(Error::Api(format!(
359 "RRSet record type mismatch: expected {}, got {}",
360 expected.as_str(),
361 r.as_type().as_str(),
362 )));
363 }
364 }
365 Ok(())
366}
367
368fn build_contents(_expected_type: DnsRecordType, records: Vec<DnsRecord>) -> Vec<String> {
369 records.iter().map(encode_record).collect()
370}
371
372fn encode_record(record: &DnsRecord) -> String {
373 match record {
374 DnsRecord::A(ip) => ip.to_string(),
375 DnsRecord::AAAA(ip) => ip.to_string(),
376 DnsRecord::CNAME(value) => value.into_fqdn().into_owned(),
377 DnsRecord::NS(value) => value.into_fqdn().into_owned(),
378 DnsRecord::MX(mx) => format!(
379 "{} {}",
380 mx.priority,
381 (&mx.exchange).into_fqdn().into_owned()
382 ),
383 DnsRecord::TXT(value) => value.clone(),
384 DnsRecord::SRV(srv) => format!(
385 "{} {} {} {}",
386 srv.priority,
387 srv.weight,
388 srv.port,
389 (&srv.target).into_fqdn().into_owned()
390 ),
391 DnsRecord::TLSA(tlsa) => tlsa.to_string(),
392 DnsRecord::CAA(caa) => caa.to_string(),
393 }
394}
395
396fn parse_record(record_type: DnsRecordType, content: &str) -> crate::Result<DnsRecord> {
397 match record_type {
398 DnsRecordType::A => content
399 .parse()
400 .map(DnsRecord::A)
401 .map_err(|e| Error::Parse(format!("invalid A record: {e}"))),
402 DnsRecordType::AAAA => content
403 .parse()
404 .map(DnsRecord::AAAA)
405 .map_err(|e| Error::Parse(format!("invalid AAAA record: {e}"))),
406 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(strip_trailing_dot(content).to_string())),
407 DnsRecordType::NS => Ok(DnsRecord::NS(strip_trailing_dot(content).to_string())),
408 DnsRecordType::MX => parse_mx(content),
409 DnsRecordType::TXT => Ok(DnsRecord::TXT(unquote_txt(content))),
410 DnsRecordType::SRV => parse_srv(content),
411 DnsRecordType::TLSA => parse_tlsa(content),
412 DnsRecordType::CAA => parse_caa(content),
413 }
414}
415
416fn parse_caa(content: &str) -> crate::Result<DnsRecord> {
417 let mut parts = content.splitn(3, ' ');
418 let flags: u8 = parts
419 .next()
420 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
421 .parse()
422 .map_err(|e| Error::Parse(format!("invalid CAA flags: {e}")))?;
423 let tag = parts
424 .next()
425 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
426 .to_string();
427 let raw_value = parts
428 .next()
429 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?;
430 let value = raw_value
431 .strip_prefix('"')
432 .and_then(|s| s.strip_suffix('"'))
433 .unwrap_or(raw_value)
434 .to_string();
435
436 let issuer_critical = flags & 0x80 != 0;
437 match tag.as_str() {
438 "issue" => {
439 let (name, options) = split_caa_value(&value);
440 Ok(DnsRecord::CAA(CAARecord::Issue {
441 issuer_critical,
442 name,
443 options,
444 }))
445 }
446 "issuewild" => {
447 let (name, options) = split_caa_value(&value);
448 Ok(DnsRecord::CAA(CAARecord::IssueWild {
449 issuer_critical,
450 name,
451 options,
452 }))
453 }
454 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
455 issuer_critical,
456 url: value,
457 })),
458 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
459 }
460}