Skip to main content

grapsus_proxy/acme/dns/providers/
hetzner.rs

1//! Hetzner DNS provider implementation
2//!
3//! Uses the Hetzner DNS API to manage TXT records for DNS-01 challenges.
4//! API documentation: <https://dns.hetzner.com/api-docs>
5
6use 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
21/// Hetzner DNS API base URL
22const HETZNER_API_BASE: &str = "https://dns.hetzner.com/api/v1";
23
24/// Hetzner DNS provider
25#[derive(Debug)]
26pub struct HetznerProvider {
27    client: Client,
28    token: String,
29    /// Cache of domain -> zone_id mappings
30    zone_cache: Arc<RwLock<HashMap<String, String>>>,
31}
32
33impl HetznerProvider {
34    /// Create a new Hetzner DNS provider
35    ///
36    /// # Arguments
37    ///
38    /// * `token` - Hetzner DNS API token
39    /// * `timeout` - Request timeout
40    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    /// Get the zone ID for a domain
53    async fn get_zone_id(&self, domain: &str) -> DnsResult<String> {
54        let normalized = normalize_domain(domain);
55
56        // Check cache first
57        {
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        // Fetch zones from API
66        let zones = self.list_zones().await?;
67
68        // Find the matching zone (try exact match first, then parent domains)
69        let zone = self.find_matching_zone(normalized, &zones)?;
70
71        // Cache the result
72        {
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    /// List all zones from Hetzner API
82    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    /// Find the matching zone for a domain
120    fn find_matching_zone<'a>(&self, domain: &str, zones: &'a [Zone]) -> DnsResult<&'a Zone> {
121        // Try exact match first
122        if let Some(zone) = zones.iter().find(|z| z.name == domain) {
123            return Ok(zone);
124        }
125
126        // Try parent domains
127        let mut current = domain;
128        while let Some(pos) = current.find('.') {
129            current = &current[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    /// Extract record name relative to zone
141    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    /// Get zone name by ID
152    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        // Build the full record name and then make it relative to zone
194        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        // 404 is fine - record might already be deleted
271        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// Hetzner API types
299
300#[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        // Direct zone
349        assert_eq!(
350            provider.record_name_for_zone("example.com", "example.com"),
351            "@"
352        );
353
354        // Subdomain
355        assert_eq!(
356            provider.record_name_for_zone("_acme-challenge.example.com", "example.com"),
357            "_acme-challenge"
358        );
359
360        // Nested subdomain
361        assert_eq!(
362            provider.record_name_for_zone("_acme-challenge.sub.example.com", "example.com"),
363            "_acme-challenge.sub"
364        );
365    }
366}