Skip to main content

dns_update/providers/
hetzner.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    CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
14    http::{HttpClient, HttpClientBuilder},
15    utils::{strip_origin_from_name, txt_chunks_to_text},
16};
17use serde::{Deserialize, Serialize};
18use std::{net::AddrParseError, time::Duration};
19
20#[derive(Clone)]
21pub struct HetznerProvider {
22    client: HttpClient,
23    endpoint: String,
24}
25
26#[derive(Serialize, Debug)]
27struct RRSetCreate<'a> {
28    name: &'a str,
29    #[serde(rename = "type")]
30    record_type: &'a str,
31    ttl: u32,
32    records: Vec<RecordValue>,
33}
34
35#[derive(Serialize, Debug)]
36struct SetRecordsBody {
37    records: Vec<RecordValue>,
38}
39
40#[derive(Serialize, Debug)]
41struct AddRecordsBody {
42    records: Vec<RecordValue>,
43    ttl: u32,
44}
45
46#[derive(Serialize, Debug)]
47struct RemoveRecordsBody {
48    records: Vec<RecordValue>,
49}
50
51#[derive(Serialize, Deserialize, Debug, Clone)]
52struct RecordValue {
53    value: String,
54}
55
56#[derive(Deserialize, Debug)]
57#[allow(dead_code)]
58struct ActionResponse {
59    #[serde(default)]
60    action: Option<serde_json::Value>,
61    #[serde(default)]
62    rrset: Option<serde_json::Value>,
63}
64
65#[derive(Deserialize, Debug)]
66struct ListRRSetsResponse {
67    #[serde(default)]
68    rrsets: Vec<ListedRRSet>,
69}
70
71#[derive(Deserialize, Debug)]
72struct ListedRRSet {
73    #[serde(rename = "type")]
74    record_type: String,
75    #[serde(default)]
76    records: Vec<RecordValue>,
77}
78
79const DEFAULT_API_ENDPOINT: &str = "https://api.hetzner.cloud/v1";
80const RETRIES: u32 = 3;
81
82impl HetznerProvider {
83    pub(crate) fn new(
84        api_token: impl AsRef<str>,
85        timeout: Option<Duration>,
86    ) -> crate::Result<Self> {
87        let token = api_token.as_ref();
88        if token.is_empty() {
89            return Err(Error::Api(
90                "Hetzner API token must not be empty".to_string(),
91            ));
92        }
93
94        let client = HttpClientBuilder::default()
95            .with_header("Authorization", format!("Bearer {token}"))
96            .with_timeout(timeout)
97            .build();
98
99        Ok(Self {
100            client,
101            endpoint: DEFAULT_API_ENDPOINT.to_string(),
102        })
103    }
104
105    #[cfg(test)]
106    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
107        Self {
108            endpoint: endpoint.as_ref().to_string(),
109            ..self
110        }
111    }
112
113    pub(crate) async fn set_rrset(
114        &self,
115        name: impl IntoFqdn<'_>,
116        record_type: DnsRecordType,
117        ttl: u32,
118        records: Vec<DnsRecord>,
119        origin: impl IntoFqdn<'_>,
120    ) -> crate::Result<()> {
121        check_record_types(record_type, &records)?;
122        reject_tlsa(record_type)?;
123        let name = name.into_name();
124        let domain = origin.into_name();
125        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
126
127        if records.is_empty() {
128            return self.delete_rrset(&domain, &subdomain, record_type).await;
129        }
130
131        let values = build_values(records)?;
132
133        let url = format!(
134            "{}/zones/{}/rrsets/{}/{}/actions/set_records",
135            self.endpoint,
136            domain,
137            subdomain,
138            record_type.as_str(),
139        );
140
141        match self
142            .client
143            .post(url)
144            .with_body(SetRecordsBody {
145                records: values.clone(),
146            })?
147            .send_with_retry::<ActionResponse>(RETRIES)
148            .await
149        {
150            Ok(_) => self.change_ttl(&domain, &subdomain, record_type, ttl).await,
151            Err(Error::NotFound) => {
152                self.create_rrset(&domain, &subdomain, record_type, ttl, values)
153                    .await
154            }
155            Err(e) => Err(e),
156        }
157    }
158
159    pub(crate) async fn add_to_rrset(
160        &self,
161        name: impl IntoFqdn<'_>,
162        record_type: DnsRecordType,
163        ttl: u32,
164        records: Vec<DnsRecord>,
165        origin: impl IntoFqdn<'_>,
166    ) -> crate::Result<()> {
167        check_record_types(record_type, &records)?;
168        reject_tlsa(record_type)?;
169        if records.is_empty() {
170            return Ok(());
171        }
172        let name = name.into_name();
173        let domain = origin.into_name();
174        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
175        let values = build_values(records)?;
176
177        let url = format!(
178            "{}/zones/{}/rrsets/{}/{}/actions/add_records",
179            self.endpoint,
180            domain,
181            subdomain,
182            record_type.as_str(),
183        );
184
185        match self
186            .client
187            .post(url)
188            .with_body(AddRecordsBody {
189                records: values.clone(),
190                ttl,
191            })?
192            .send_with_retry::<ActionResponse>(RETRIES)
193            .await
194        {
195            Ok(_) => Ok(()),
196            Err(Error::NotFound) => {
197                self.create_rrset(&domain, &subdomain, record_type, ttl, values)
198                    .await
199            }
200            Err(e) => Err(e),
201        }
202    }
203
204    pub(crate) async fn remove_from_rrset(
205        &self,
206        name: impl IntoFqdn<'_>,
207        record_type: DnsRecordType,
208        records: Vec<DnsRecord>,
209        origin: impl IntoFqdn<'_>,
210    ) -> crate::Result<()> {
211        check_record_types(record_type, &records)?;
212        reject_tlsa(record_type)?;
213        if records.is_empty() {
214            return Ok(());
215        }
216        let name = name.into_name();
217        let domain = origin.into_name();
218        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
219        let values = build_values(records)?;
220
221        let url = format!(
222            "{}/zones/{}/rrsets/{}/{}/actions/remove_records",
223            self.endpoint,
224            domain,
225            subdomain,
226            record_type.as_str(),
227        );
228
229        match self
230            .client
231            .post(url)
232            .with_body(RemoveRecordsBody { records: values })?
233            .send_with_retry::<ActionResponse>(RETRIES)
234            .await
235        {
236            Ok(_) => Ok(()),
237            Err(Error::NotFound) => Ok(()),
238            Err(e) => Err(e),
239        }
240    }
241
242    pub(crate) async fn list_rrset(
243        &self,
244        name: impl IntoFqdn<'_>,
245        record_type: DnsRecordType,
246        origin: impl IntoFqdn<'_>,
247    ) -> crate::Result<Vec<DnsRecord>> {
248        let name = name.into_name();
249        let domain = origin.into_name();
250        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
251
252        let query = serde_urlencoded::to_string([
253            ("name", subdomain.as_str()),
254            ("type", record_type.as_str()),
255            ("per_page", "50"),
256        ])
257        .map_err(|e| Error::Serialize(e.to_string()))?;
258
259        let url = format!("{}/zones/{}/rrsets?{}", self.endpoint, domain, query);
260
261        let response: ListRRSetsResponse = match self.client.get(url).send_with_retry(RETRIES).await
262        {
263            Ok(r) => r,
264            Err(Error::NotFound) => return Ok(Vec::new()),
265            Err(e) => return Err(e),
266        };
267
268        let expected_type = record_type.as_str();
269        let mut out = Vec::new();
270        for rrset in response.rrsets {
271            if rrset.record_type != expected_type {
272                continue;
273            }
274            for r in rrset.records {
275                out.push(parse_value(record_type, &r.value)?);
276            }
277        }
278        Ok(out)
279    }
280
281    async fn create_rrset(
282        &self,
283        domain: &str,
284        subdomain: &str,
285        record_type: DnsRecordType,
286        ttl: u32,
287        values: Vec<RecordValue>,
288    ) -> crate::Result<()> {
289        self.client
290            .post(format!("{}/zones/{}/rrsets", self.endpoint, domain))
291            .with_body(RRSetCreate {
292                name: subdomain,
293                record_type: record_type.as_str(),
294                ttl,
295                records: values,
296            })?
297            .send_with_retry::<ActionResponse>(RETRIES)
298            .await
299            .map(|_| ())
300    }
301
302    async fn delete_rrset(
303        &self,
304        domain: &str,
305        subdomain: &str,
306        record_type: DnsRecordType,
307    ) -> crate::Result<()> {
308        match self
309            .client
310            .delete(format!(
311                "{}/zones/{}/rrsets/{}/{}",
312                self.endpoint,
313                domain,
314                subdomain,
315                record_type.as_str(),
316            ))
317            .send_raw()
318            .await
319        {
320            Ok(_) => Ok(()),
321            Err(Error::NotFound) => Ok(()),
322            Err(e) => Err(e),
323        }
324    }
325
326    async fn change_ttl(
327        &self,
328        domain: &str,
329        subdomain: &str,
330        record_type: DnsRecordType,
331        ttl: u32,
332    ) -> crate::Result<()> {
333        #[derive(Serialize)]
334        struct ChangeTtl {
335            ttl: u32,
336        }
337
338        let url = format!(
339            "{}/zones/{}/rrsets/{}/{}/actions/change_ttl",
340            self.endpoint,
341            domain,
342            subdomain,
343            record_type.as_str(),
344        );
345
346        self.client
347            .post(url)
348            .with_body(ChangeTtl { ttl })?
349            .send_with_retry::<ActionResponse>(RETRIES)
350            .await
351            .map(|_| ())
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 reject_tlsa(record_type: DnsRecordType) -> crate::Result<()> {
369    if record_type == DnsRecordType::TLSA {
370        Err(Error::Unsupported(
371            "TLSA records are not supported by Hetzner".to_string(),
372        ))
373    } else {
374        Ok(())
375    }
376}
377
378fn build_values(records: Vec<DnsRecord>) -> crate::Result<Vec<RecordValue>> {
379    let mut out = Vec::with_capacity(records.len());
380    for record in records {
381        out.push(RecordValue {
382            value: render_value(record)?,
383        });
384    }
385    Ok(out)
386}
387
388fn render_value(record: DnsRecord) -> crate::Result<String> {
389    Ok(match record {
390        DnsRecord::A(addr) => addr.to_string(),
391        DnsRecord::AAAA(addr) => addr.to_string(),
392        DnsRecord::CNAME(content) => ensure_trailing_dot(content),
393        DnsRecord::NS(content) => ensure_trailing_dot(content),
394        DnsRecord::MX(mx) => format!("{} {}", mx.priority, ensure_trailing_dot(mx.exchange)),
395        DnsRecord::TXT(content) => {
396            let mut out = String::with_capacity(content.len() + 4);
397            txt_chunks_to_text(&mut out, &content, " ");
398            out
399        }
400        DnsRecord::SRV(srv) => format!(
401            "{} {} {} {}",
402            srv.priority,
403            srv.weight,
404            srv.port,
405            ensure_trailing_dot(srv.target),
406        ),
407        DnsRecord::TLSA(_) => {
408            return Err(Error::Unsupported(
409                "TLSA records are not supported by Hetzner".to_string(),
410            ));
411        }
412        DnsRecord::CAA(caa) => caa.to_string(),
413    })
414}
415
416fn parse_value(record_type: DnsRecordType, value: &str) -> crate::Result<DnsRecord> {
417    Ok(match record_type {
418        DnsRecordType::A => DnsRecord::A(value.parse().map_err(|e: AddrParseError| {
419            Error::Parse(format!("invalid A value '{value}': {e}"))
420        })?),
421        DnsRecordType::AAAA => DnsRecord::AAAA(value.parse().map_err(|e: AddrParseError| {
422            Error::Parse(format!("invalid AAAA value '{value}': {e}"))
423        })?),
424        DnsRecordType::CNAME => DnsRecord::CNAME(strip_trailing_dot(value)),
425        DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(value)),
426        DnsRecordType::MX => parse_mx(value)?,
427        DnsRecordType::TXT => DnsRecord::TXT(parse_txt(value)),
428        DnsRecordType::SRV => parse_srv(value)?,
429        DnsRecordType::TLSA => {
430            return Err(Error::Unsupported(
431                "TLSA records are not supported by Hetzner".to_string(),
432            ));
433        }
434        DnsRecordType::CAA => parse_caa(value)?,
435    })
436}
437
438fn parse_mx(value: &str) -> crate::Result<DnsRecord> {
439    let mut parts = value.splitn(2, char::is_whitespace);
440    let priority = parts
441        .next()
442        .ok_or_else(|| Error::Parse(format!("invalid MX value '{value}'")))?
443        .parse()
444        .map_err(|e| Error::Parse(format!("invalid MX priority in '{value}': {e}")))?;
445    let exchange = parts
446        .next()
447        .ok_or_else(|| Error::Parse(format!("invalid MX value '{value}'")))?
448        .trim();
449    Ok(DnsRecord::MX(MXRecord {
450        priority,
451        exchange: strip_trailing_dot(exchange),
452    }))
453}
454
455fn parse_srv(value: &str) -> crate::Result<DnsRecord> {
456    let mut parts = value.split_whitespace();
457    let priority = parts
458        .next()
459        .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
460        .parse()
461        .map_err(|e| Error::Parse(format!("invalid SRV priority in '{value}': {e}")))?;
462    let weight = parts
463        .next()
464        .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
465        .parse()
466        .map_err(|e| Error::Parse(format!("invalid SRV weight in '{value}': {e}")))?;
467    let port = parts
468        .next()
469        .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
470        .parse()
471        .map_err(|e| Error::Parse(format!("invalid SRV port in '{value}': {e}")))?;
472    let target = parts
473        .next()
474        .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?;
475    Ok(DnsRecord::SRV(SRVRecord {
476        priority,
477        weight,
478        port,
479        target: strip_trailing_dot(target),
480    }))
481}
482
483fn parse_txt(value: &str) -> String {
484    let trimmed = value.trim();
485    let mut out = String::with_capacity(trimmed.len());
486    let mut bytes = trimmed.bytes().peekable();
487    while let Some(&b) = bytes.peek() {
488        if b != b'"' {
489            bytes.next();
490            continue;
491        }
492        bytes.next();
493        loop {
494            match bytes.next() {
495                Some(b'"') => break,
496                Some(b'\\') => {
497                    if let Some(next) = bytes.next() {
498                        out.push(next as char);
499                    }
500                }
501                Some(other) => out.push(other as char),
502                None => break,
503            }
504        }
505    }
506    if out.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('"') {
507        return trimmed.to_string();
508    }
509    out
510}
511
512fn parse_caa(value: &str) -> crate::Result<DnsRecord> {
513    let mut parts = value.splitn(3, char::is_whitespace);
514    let flags: u8 = parts
515        .next()
516        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
517        .parse()
518        .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
519    let tag = parts
520        .next()
521        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
522        .to_ascii_lowercase();
523    let raw_value = parts
524        .next()
525        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
526        .trim();
527    let unquoted = raw_value
528        .strip_prefix('"')
529        .and_then(|s| s.strip_suffix('"'))
530        .map(|s| s.replace("\\\"", "\""))
531        .unwrap_or_else(|| raw_value.to_string());
532
533    let issuer_critical = flags & 0x80 != 0;
534    match tag.as_str() {
535        "issue" => {
536            let (name, options) = parse_caa_kv(&unquoted);
537            Ok(DnsRecord::CAA(CAARecord::Issue {
538                issuer_critical,
539                name,
540                options,
541            }))
542        }
543        "issuewild" => {
544            let (name, options) = parse_caa_kv(&unquoted);
545            Ok(DnsRecord::CAA(CAARecord::IssueWild {
546                issuer_critical,
547                name,
548                options,
549            }))
550        }
551        "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
552            issuer_critical,
553            url: unquoted,
554        })),
555        other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
556    }
557}
558
559fn parse_caa_kv(value: &str) -> (Option<String>, Vec<KeyValue>) {
560    let mut parts = value.split(';').map(str::trim);
561    let name_part = parts.next().unwrap_or("").trim().to_string();
562    let name = if name_part.is_empty() {
563        None
564    } else {
565        Some(name_part)
566    };
567    let options = parts
568        .filter(|p| !p.is_empty())
569        .map(|p| match p.split_once('=') {
570            Some((k, v)) => KeyValue {
571                key: k.trim().to_string(),
572                value: v.trim().to_string(),
573            },
574            None => KeyValue {
575                key: p.trim().to_string(),
576                value: String::new(),
577            },
578        })
579        .collect();
580    (name, options)
581}
582
583fn ensure_trailing_dot(value: String) -> String {
584    if value.ends_with('.') {
585        value
586    } else {
587        format!("{value}.")
588    }
589}
590
591fn strip_trailing_dot(value: &str) -> String {
592    value.strip_suffix('.').unwrap_or(value).to_string()
593}