Skip to main content

dns_update/providers/
hostinger.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use 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}