1use 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}