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::{
13    DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
14    utils::strip_origin_from_name,
15};
16use serde::{Deserialize, Serialize};
17use std::time::Duration;
18
19const DEFAULT_ENDPOINT: &str = "https://developers.hostinger.com";
20
21#[derive(Clone)]
22pub struct HostingerProvider {
23    client: HttpClientBuilder,
24    endpoint: String,
25}
26
27#[derive(Serialize, Debug)]
28pub struct ZoneRequest {
29    pub overwrite: bool,
30    #[serde(skip_serializing_if = "Vec::is_empty")]
31    pub zone: Vec<RecordSet>,
32}
33
34#[derive(Serialize, Deserialize, Debug, Clone)]
35pub struct RecordSet {
36    pub name: String,
37    #[serde(rename = "type")]
38    pub record_type: String,
39    pub ttl: u32,
40    pub records: Vec<RecordValue>,
41}
42
43#[derive(Serialize, Deserialize, Debug, Clone)]
44pub struct RecordValue {
45    pub content: String,
46    #[serde(default, skip_serializing_if = "is_false")]
47    pub is_disabled: bool,
48}
49
50#[derive(Serialize, Debug)]
51pub struct Filters {
52    pub filters: Vec<Filter>,
53}
54
55#[derive(Serialize, Debug)]
56pub struct Filter {
57    pub name: String,
58    #[serde(rename = "type")]
59    pub record_type: String,
60}
61
62fn is_false(value: &bool) -> bool {
63    !*value
64}
65
66impl HostingerProvider {
67    pub(crate) fn new(
68        api_token: impl AsRef<str>,
69        timeout: Option<Duration>,
70    ) -> crate::Result<Self> {
71        let token = api_token.as_ref();
72        if token.is_empty() {
73            return Err(Error::Api("Hostinger API token is empty".to_string()));
74        }
75        let client = HttpClientBuilder::default()
76            .with_header("Authorization", format!("Bearer {token}"))
77            .with_header("Accept", "application/json")
78            .with_timeout(timeout);
79        Ok(Self {
80            client,
81            endpoint: DEFAULT_ENDPOINT.to_string(),
82        })
83    }
84
85    #[cfg(test)]
86    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
87        Self {
88            endpoint: endpoint.as_ref().to_string(),
89            ..self
90        }
91    }
92
93    fn zone_url(&self, domain: &str) -> String {
94        format!("{}/api/dns/v1/zones/{}", self.endpoint, domain)
95    }
96
97    pub(crate) async fn create(
98        &self,
99        name: impl IntoFqdn<'_>,
100        record: DnsRecord,
101        ttl: u32,
102        origin: impl IntoFqdn<'_>,
103    ) -> crate::Result<()> {
104        let name = name.into_name();
105        let domain = origin.into_name();
106        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
107        let record_type = record.as_type();
108
109        let new_value = encode_record(&record);
110
111        let existing = self.fetch_record_set(&domain, &subdomain, record_type).await?;
112        let mut records = existing.map(|r| r.records).unwrap_or_default();
113        if !records.iter().any(|r| r.content == new_value) {
114            records.push(RecordValue {
115                content: new_value,
116                is_disabled: false,
117            });
118        }
119
120        let request = ZoneRequest {
121            overwrite: true,
122            zone: vec![RecordSet {
123                name: subdomain,
124                record_type: record_type.as_str().to_string(),
125                ttl,
126                records,
127            }],
128        };
129
130        self.client
131            .put(self.zone_url(&domain))
132            .with_body(request)?
133            .send_raw()
134            .await
135            .map(|_| ())
136    }
137
138    pub(crate) async fn update(
139        &self,
140        name: impl IntoFqdn<'_>,
141        record: DnsRecord,
142        ttl: u32,
143        origin: impl IntoFqdn<'_>,
144    ) -> crate::Result<()> {
145        let name = name.into_name();
146        let domain = origin.into_name();
147        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
148        let record_type = record.as_type();
149        let new_value = encode_record(&record);
150
151        let request = ZoneRequest {
152            overwrite: true,
153            zone: vec![RecordSet {
154                name: subdomain,
155                record_type: record_type.as_str().to_string(),
156                ttl,
157                records: vec![RecordValue {
158                    content: new_value,
159                    is_disabled: false,
160                }],
161            }],
162        };
163
164        self.client
165            .put(self.zone_url(&domain))
166            .with_body(request)?
167            .send_raw()
168            .await
169            .map(|_| ())
170    }
171
172    pub(crate) async fn delete(
173        &self,
174        name: impl IntoFqdn<'_>,
175        origin: impl IntoFqdn<'_>,
176        record_type: DnsRecordType,
177    ) -> crate::Result<()> {
178        let name = name.into_name();
179        let domain = origin.into_name();
180        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
181
182        let request = Filters {
183            filters: vec![Filter {
184                name: subdomain,
185                record_type: record_type.as_str().to_string(),
186            }],
187        };
188
189        self.client
190            .delete(self.zone_url(&domain))
191            .with_body(request)?
192            .send_raw()
193            .await
194            .map(|_| ())
195    }
196
197    async fn fetch_record_set(
198        &self,
199        domain: &str,
200        subdomain: &str,
201        record_type: DnsRecordType,
202    ) -> crate::Result<Option<RecordSet>> {
203        let response = self.client.get(self.zone_url(domain)).send_raw().await?;
204        if response.is_empty() {
205            return Ok(None);
206        }
207        let parsed: Vec<RecordSet> = serde_json::from_str(&response).map_err(|err| {
208            Error::Serialize(format!("Failed to deserialize Hostinger zone: {err}"))
209        })?;
210        Ok(parsed
211            .into_iter()
212            .find(|r| r.name == subdomain && r.record_type == record_type.as_str()))
213    }
214}
215
216fn encode_record(record: &DnsRecord) -> String {
217    match record {
218        DnsRecord::A(ip) => ip.to_string(),
219        DnsRecord::AAAA(ip) => ip.to_string(),
220        DnsRecord::CNAME(value) => value.clone(),
221        DnsRecord::NS(value) => value.clone(),
222        DnsRecord::MX(mx) => format!("{} {}", mx.priority, mx.exchange),
223        DnsRecord::TXT(value) => value.clone(),
224        DnsRecord::SRV(srv) => format!(
225            "{} {} {} {}",
226            srv.priority, srv.weight, srv.port, srv.target
227        ),
228        DnsRecord::TLSA(tlsa) => tlsa.to_string(),
229        DnsRecord::CAA(caa) => caa.to_string(),
230    }
231}