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