grapsus_proxy/acme/dns/providers/
hetzner.rs1use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use parking_lot::RwLock;
12use reqwest::Client;
13use serde::{Deserialize, Serialize};
14use tracing::{debug, trace};
15
16use crate::acme::dns::provider::{
17 challenge_record_fqdn, normalize_domain, DnsProvider, DnsProviderError, DnsResult,
18 CHALLENGE_TTL,
19};
20
21const HETZNER_API_BASE: &str = "https://dns.hetzner.com/api/v1";
23
24#[derive(Debug)]
26pub struct HetznerProvider {
27 client: Client,
28 token: String,
29 zone_cache: Arc<RwLock<HashMap<String, String>>>,
31}
32
33impl HetznerProvider {
34 pub fn new(token: &str, timeout: Duration) -> DnsResult<Self> {
41 let client = Client::builder().timeout(timeout).build().map_err(|e| {
42 DnsProviderError::Configuration(format!("Failed to create HTTP client: {}", e))
43 })?;
44
45 Ok(Self {
46 client,
47 token: token.to_string(),
48 zone_cache: Arc::new(RwLock::new(HashMap::new())),
49 })
50 }
51
52 async fn get_zone_id(&self, domain: &str) -> DnsResult<String> {
54 let normalized = normalize_domain(domain);
55
56 {
58 let cache = self.zone_cache.read();
59 if let Some(zone_id) = cache.get(normalized) {
60 trace!(domain = %domain, zone_id = %zone_id, "Zone ID found in cache");
61 return Ok(zone_id.clone());
62 }
63 }
64
65 let zones = self.list_zones().await?;
67
68 let zone = self.find_matching_zone(normalized, &zones)?;
70
71 {
73 let mut cache = self.zone_cache.write();
74 cache.insert(normalized.to_string(), zone.id.clone());
75 }
76
77 debug!(domain = %domain, zone_id = %zone.id, zone_name = %zone.name, "Found zone for domain");
78 Ok(zone.id.clone())
79 }
80
81 async fn list_zones(&self) -> DnsResult<Vec<Zone>> {
83 let response = self
84 .client
85 .get(format!("{}/zones", HETZNER_API_BASE))
86 .header("Auth-API-Token", &self.token)
87 .send()
88 .await
89 .map_err(|e| {
90 if e.is_timeout() {
91 DnsProviderError::Timeout { elapsed_secs: 30 }
92 } else {
93 DnsProviderError::ApiRequest(format!("Failed to list zones: {}", e))
94 }
95 })?;
96
97 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
98 return Err(DnsProviderError::Authentication(
99 "Invalid Hetzner API token".to_string(),
100 ));
101 }
102
103 if !response.status().is_success() {
104 let status = response.status();
105 let body = response.text().await.unwrap_or_default();
106 return Err(DnsProviderError::ApiRequest(format!(
107 "Failed to list zones: HTTP {} - {}",
108 status, body
109 )));
110 }
111
112 let zones_response: ZonesResponse = response.json().await.map_err(|e| {
113 DnsProviderError::ApiRequest(format!("Failed to parse zones response: {}", e))
114 })?;
115
116 Ok(zones_response.zones)
117 }
118
119 fn find_matching_zone<'a>(&self, domain: &str, zones: &'a [Zone]) -> DnsResult<&'a Zone> {
121 if let Some(zone) = zones.iter().find(|z| z.name == domain) {
123 return Ok(zone);
124 }
125
126 let mut current = domain;
128 while let Some(pos) = current.find('.') {
129 current = ¤t[pos + 1..];
130 if let Some(zone) = zones.iter().find(|z| z.name == current) {
131 return Ok(zone);
132 }
133 }
134
135 Err(DnsProviderError::ZoneNotFound {
136 domain: domain.to_string(),
137 })
138 }
139
140 fn record_name_for_zone(&self, fqdn: &str, zone_name: &str) -> String {
142 if fqdn == zone_name {
143 "@".to_string()
144 } else if let Some(stripped) = fqdn.strip_suffix(&format!(".{}", zone_name)) {
145 stripped.to_string()
146 } else {
147 fqdn.to_string()
148 }
149 }
150
151 async fn get_zone_name(&self, zone_id: &str) -> DnsResult<String> {
153 let response = self
154 .client
155 .get(format!("{}/zones/{}", HETZNER_API_BASE, zone_id))
156 .header("Auth-API-Token", &self.token)
157 .send()
158 .await
159 .map_err(|e| DnsProviderError::ApiRequest(format!("Failed to get zone: {}", e)))?;
160
161 if !response.status().is_success() {
162 let status = response.status();
163 let body = response.text().await.unwrap_or_default();
164 return Err(DnsProviderError::ApiRequest(format!(
165 "Failed to get zone: HTTP {} - {}",
166 status, body
167 )));
168 }
169
170 let zone_response: ZoneResponse = response.json().await.map_err(|e| {
171 DnsProviderError::ApiRequest(format!("Failed to parse zone response: {}", e))
172 })?;
173
174 Ok(zone_response.zone.name)
175 }
176}
177
178#[async_trait]
179impl DnsProvider for HetznerProvider {
180 fn name(&self) -> &'static str {
181 "hetzner"
182 }
183
184 async fn create_txt_record(
185 &self,
186 domain: &str,
187 record_name: &str,
188 record_value: &str,
189 ) -> DnsResult<String> {
190 let zone_id = self.get_zone_id(domain).await?;
191 let zone_name = self.get_zone_name(&zone_id).await?;
192
193 let fqdn = format!("{}.{}", record_name, normalize_domain(domain));
195 let relative_name = self.record_name_for_zone(&fqdn, &zone_name);
196
197 debug!(
198 domain = %domain,
199 zone_id = %zone_id,
200 record_name = %relative_name,
201 "Creating TXT record"
202 );
203
204 let request = CreateRecordRequest {
205 zone_id: zone_id.clone(),
206 name: relative_name.clone(),
207 r#type: "TXT".to_string(),
208 value: record_value.to_string(),
209 ttl: Some(CHALLENGE_TTL),
210 };
211
212 let response = self
213 .client
214 .post(format!("{}/records", HETZNER_API_BASE))
215 .header("Auth-API-Token", &self.token)
216 .json(&request)
217 .send()
218 .await
219 .map_err(|e| {
220 if e.is_timeout() {
221 DnsProviderError::Timeout { elapsed_secs: 30 }
222 } else {
223 DnsProviderError::ApiRequest(format!("Failed to create record: {}", e))
224 }
225 })?;
226
227 if !response.status().is_success() {
228 let status = response.status();
229 let body = response.text().await.unwrap_or_default();
230 return Err(DnsProviderError::RecordCreation {
231 record_name: relative_name,
232 message: format!("HTTP {} - {}", status, body),
233 });
234 }
235
236 let record_response: RecordResponse =
237 response
238 .json()
239 .await
240 .map_err(|e| DnsProviderError::RecordCreation {
241 record_name: relative_name.clone(),
242 message: format!("Failed to parse response: {}", e),
243 })?;
244
245 debug!(
246 record_id = %record_response.record.id,
247 "TXT record created successfully"
248 );
249
250 Ok(record_response.record.id)
251 }
252
253 async fn delete_txt_record(&self, _domain: &str, record_id: &str) -> DnsResult<()> {
254 debug!(record_id = %record_id, "Deleting TXT record");
255
256 let response = self
257 .client
258 .delete(format!("{}/records/{}", HETZNER_API_BASE, record_id))
259 .header("Auth-API-Token", &self.token)
260 .send()
261 .await
262 .map_err(|e| {
263 if e.is_timeout() {
264 DnsProviderError::Timeout { elapsed_secs: 30 }
265 } else {
266 DnsProviderError::ApiRequest(format!("Failed to delete record: {}", e))
267 }
268 })?;
269
270 if response.status() == reqwest::StatusCode::NOT_FOUND {
272 debug!(record_id = %record_id, "Record already deleted");
273 return Ok(());
274 }
275
276 if !response.status().is_success() {
277 let status = response.status();
278 let body = response.text().await.unwrap_or_default();
279 return Err(DnsProviderError::RecordDeletion {
280 record_id: record_id.to_string(),
281 message: format!("HTTP {} - {}", status, body),
282 });
283 }
284
285 debug!(record_id = %record_id, "TXT record deleted successfully");
286 Ok(())
287 }
288
289 async fn supports_domain(&self, domain: &str) -> DnsResult<bool> {
290 match self.get_zone_id(domain).await {
291 Ok(_) => Ok(true),
292 Err(DnsProviderError::ZoneNotFound { .. }) => Ok(false),
293 Err(e) => Err(e),
294 }
295 }
296}
297
298#[derive(Debug, Deserialize)]
301struct ZonesResponse {
302 zones: Vec<Zone>,
303}
304
305#[derive(Debug, Deserialize)]
306struct ZoneResponse {
307 zone: Zone,
308}
309
310#[derive(Debug, Deserialize)]
311struct Zone {
312 id: String,
313 name: String,
314}
315
316#[derive(Debug, Serialize)]
317struct CreateRecordRequest {
318 zone_id: String,
319 name: String,
320 r#type: String,
321 value: String,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 ttl: Option<u32>,
324}
325
326#[derive(Debug, Deserialize)]
327struct RecordResponse {
328 record: Record,
329}
330
331#[derive(Debug, Deserialize)]
332struct Record {
333 id: String,
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn test_record_name_for_zone() {
342 let provider = HetznerProvider {
343 client: Client::new(),
344 token: "test".to_string(),
345 zone_cache: Arc::new(RwLock::new(HashMap::new())),
346 };
347
348 assert_eq!(
350 provider.record_name_for_zone("example.com", "example.com"),
351 "@"
352 );
353
354 assert_eq!(
356 provider.record_name_for_zone("_acme-challenge.example.com", "example.com"),
357 "_acme-challenge"
358 );
359
360 assert_eq!(
362 provider.record_name_for_zone("_acme-challenge.sub.example.com", "example.com"),
363 "_acme-challenge.sub"
364 );
365 }
366}