1use crate::utils::parse_tlsa;
13use crate::utils::split_caa_value;
14use crate::utils::strip_trailing_dot;
15use crate::{
16 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord,
17 http::{HttpClient, HttpClientBuilder},
18 utils::strip_origin_from_name,
19};
20use serde::{Deserialize, Serialize};
21use std::{
22 net::AddrParseError,
23 sync::Arc,
24 time::{Duration, Instant},
25};
26use tokio::sync::Mutex;
27
28const DEFAULT_ENDPOINT: &str = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON";
29const SESSION_TTL_SECS: u64 = 10 * 60;
30
31#[derive(Clone)]
32pub struct NetcupProvider {
33 client: HttpClient,
34 endpoint: String,
35 customer_number: String,
36 api_key: String,
37 api_password: String,
38 session: Arc<Mutex<Option<(String, Instant)>>>,
39}
40
41#[derive(Serialize, Debug)]
42struct Request<P: Serialize> {
43 action: &'static str,
44 param: P,
45}
46
47#[derive(Serialize, Debug)]
48struct LoginParam<'a> {
49 customernumber: &'a str,
50 apikey: &'a str,
51 apipassword: &'a str,
52}
53
54#[derive(Serialize, Debug)]
55struct LogoutParam<'a> {
56 customernumber: &'a str,
57 apikey: &'a str,
58 apisessionid: &'a str,
59}
60
61#[derive(Serialize, Debug)]
62struct InfoDnsRecordsParam<'a> {
63 domainname: &'a str,
64 customernumber: &'a str,
65 apikey: &'a str,
66 apisessionid: &'a str,
67}
68
69#[derive(Serialize, Debug)]
70struct UpdateDnsRecordsParam<'a> {
71 domainname: &'a str,
72 customernumber: &'a str,
73 apikey: &'a str,
74 apisessionid: &'a str,
75 dnsrecordset: DnsRecordSet,
76}
77
78#[derive(Serialize, Debug)]
79struct DnsRecordSet {
80 dnsrecords: Vec<NetcupRecord>,
81}
82
83#[derive(Serialize, Deserialize, Clone, Debug)]
84struct NetcupRecord {
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 id: Option<String>,
87 hostname: String,
88 #[serde(rename = "type")]
89 record_type: String,
90 #[serde(default, skip_serializing_if = "String::is_empty")]
91 priority: String,
92 destination: String,
93 #[serde(default, skip_serializing_if = "is_false")]
94 deleterecord: bool,
95 #[serde(default, skip_serializing_if = "String::is_empty")]
96 state: String,
97}
98
99fn is_false(v: &bool) -> bool {
100 !*v
101}
102
103#[derive(Deserialize, Debug)]
104struct ResponseMsg {
105 #[serde(default)]
106 status: String,
107 #[serde(default, rename = "statuscode")]
108 status_code: i64,
109 #[serde(default, rename = "shortmessage")]
110 short_message: String,
111 #[serde(default, rename = "longmessage")]
112 long_message: String,
113 #[serde(default, rename = "responsedata")]
114 response_data: serde_json::Value,
115}
116
117#[derive(Deserialize, Debug)]
118struct LoginResponse {
119 #[serde(default, rename = "apisessionid")]
120 api_session_id: String,
121}
122
123#[derive(Deserialize, Debug)]
124struct InfoDnsRecordsResponse {
125 #[serde(default)]
126 dnsrecords: Vec<NetcupRecord>,
127}
128
129impl NetcupProvider {
130 pub(crate) fn new(
131 customer_number: impl AsRef<str>,
132 api_key: impl AsRef<str>,
133 api_password: impl AsRef<str>,
134 timeout: Option<Duration>,
135 ) -> Self {
136 let client = HttpClientBuilder::default().with_timeout(timeout).build();
137 Self {
138 client,
139 endpoint: DEFAULT_ENDPOINT.to_string(),
140 customer_number: customer_number.as_ref().to_string(),
141 api_key: api_key.as_ref().to_string(),
142 api_password: api_password.as_ref().to_string(),
143 session: Arc::new(Mutex::new(None)),
144 }
145 }
146
147 #[cfg(test)]
148 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
149 Self {
150 endpoint: endpoint.as_ref().to_string(),
151 ..self
152 }
153 }
154
155 pub(crate) async fn set_rrset(
156 &self,
157 name: impl IntoFqdn<'_>,
158 record_type: DnsRecordType,
159 _ttl: u32,
160 records: Vec<DnsRecord>,
161 origin: impl IntoFqdn<'_>,
162 ) -> crate::Result<()> {
163 check_record_types(record_type, &records)?;
164 let name = name.into_name().into_owned();
165 let origin = origin.into_name().into_owned();
166 let hostname = strip_origin_from_name(&name, &origin, Some("@"));
167 let session = self.ensure_session().await?;
168 let listed = self.list_all_records(&origin, &session).await?;
169
170 let type_str = record_type.as_str();
171 let existing: Vec<NetcupRecord> = listed
172 .into_iter()
173 .filter(|r| r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str))
174 .collect();
175
176 let desired: Vec<NetcupRecord> = records
177 .iter()
178 .map(|r| encode_record(r, &hostname))
179 .collect::<crate::Result<Vec<_>>>()?;
180
181 let mut batch: Vec<NetcupRecord> = Vec::new();
182 let mut remaining: Vec<NetcupRecord> = existing.clone();
183
184 for want in &desired {
185 if let Some(idx) = remaining.iter().position(|r| same_payload(r, want)) {
186 remaining.swap_remove(idx);
187 } else {
188 batch.push(want.clone());
189 }
190 }
191 for stale in remaining {
192 batch.push(NetcupRecord {
193 id: stale.id,
194 hostname: stale.hostname,
195 record_type: stale.record_type,
196 priority: stale.priority,
197 destination: stale.destination,
198 deleterecord: true,
199 state: String::new(),
200 });
201 }
202
203 if batch.is_empty() {
204 return Ok(());
205 }
206 self.update_dns_records(&origin, &session, batch).await
207 }
208
209 pub(crate) async fn add_to_rrset(
210 &self,
211 name: impl IntoFqdn<'_>,
212 record_type: DnsRecordType,
213 _ttl: u32,
214 records: Vec<DnsRecord>,
215 origin: impl IntoFqdn<'_>,
216 ) -> crate::Result<()> {
217 check_record_types(record_type, &records)?;
218 if records.is_empty() {
219 return Ok(());
220 }
221 let name = name.into_name().into_owned();
222 let origin = origin.into_name().into_owned();
223 let hostname = strip_origin_from_name(&name, &origin, Some("@"));
224 let session = self.ensure_session().await?;
225 let listed = self.list_all_records(&origin, &session).await?;
226
227 let type_str = record_type.as_str();
228 let existing: Vec<NetcupRecord> = listed
229 .into_iter()
230 .filter(|r| r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str))
231 .collect();
232
233 let desired: Vec<NetcupRecord> = records
234 .iter()
235 .map(|r| encode_record(r, &hostname))
236 .collect::<crate::Result<Vec<_>>>()?;
237
238 let mut batch: Vec<NetcupRecord> = Vec::new();
239 for want in desired {
240 if !existing.iter().any(|r| same_payload(r, &want)) {
241 batch.push(want);
242 }
243 }
244
245 if batch.is_empty() {
246 return Ok(());
247 }
248 self.update_dns_records(&origin, &session, batch).await
249 }
250
251 pub(crate) async fn remove_from_rrset(
252 &self,
253 name: impl IntoFqdn<'_>,
254 record_type: DnsRecordType,
255 records: Vec<DnsRecord>,
256 origin: impl IntoFqdn<'_>,
257 ) -> crate::Result<()> {
258 check_record_types(record_type, &records)?;
259 if records.is_empty() {
260 return Ok(());
261 }
262 let name = name.into_name().into_owned();
263 let origin = origin.into_name().into_owned();
264 let hostname = strip_origin_from_name(&name, &origin, Some("@"));
265 let session = self.ensure_session().await?;
266 let listed = self.list_all_records(&origin, &session).await?;
267
268 let type_str = record_type.as_str();
269 let existing: Vec<NetcupRecord> = listed
270 .into_iter()
271 .filter(|r| r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str))
272 .collect();
273
274 let targets: Vec<NetcupRecord> = records
275 .iter()
276 .map(|r| encode_record(r, &hostname))
277 .collect::<crate::Result<Vec<_>>>()?;
278
279 let mut batch: Vec<NetcupRecord> = Vec::new();
280 for target in &targets {
281 if let Some(found) = existing.iter().find(|r| same_payload(r, target)) {
282 batch.push(NetcupRecord {
283 id: found.id.clone(),
284 hostname: found.hostname.clone(),
285 record_type: found.record_type.clone(),
286 priority: found.priority.clone(),
287 destination: found.destination.clone(),
288 deleterecord: true,
289 state: String::new(),
290 });
291 }
292 }
293
294 if batch.is_empty() {
295 return Ok(());
296 }
297 self.update_dns_records(&origin, &session, batch).await
298 }
299
300 pub(crate) async fn list_rrset(
301 &self,
302 name: impl IntoFqdn<'_>,
303 record_type: DnsRecordType,
304 origin: impl IntoFqdn<'_>,
305 ) -> crate::Result<Vec<DnsRecord>> {
306 let name = name.into_name().into_owned();
307 let origin = origin.into_name().into_owned();
308 let hostname = strip_origin_from_name(&name, &origin, Some("@"));
309 let session = self.ensure_session().await?;
310 let listed = self.list_all_records(&origin, &session).await?;
311
312 let type_str = record_type.as_str();
313 let mut out = Vec::new();
314 for r in listed {
315 if r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str) {
316 out.push(decode_record(record_type, &r)?);
317 }
318 }
319 Ok(out)
320 }
321
322 async fn ensure_session(&self) -> crate::Result<String> {
323 let mut guard = self.session.lock().await;
324 if let Some((ref id, expiry)) = *guard
325 && Instant::now() < expiry
326 {
327 return Ok(id.clone());
328 }
329 let id = self.login().await?;
330 let expiry = Instant::now() + Duration::from_secs(SESSION_TTL_SECS);
331 *guard = Some((id.clone(), expiry));
332 Ok(id)
333 }
334
335 async fn login(&self) -> crate::Result<String> {
336 let payload = Request {
337 action: "login",
338 param: LoginParam {
339 customernumber: &self.customer_number,
340 apikey: &self.api_key,
341 apipassword: &self.api_password,
342 },
343 };
344 let response: ResponseMsg = self
345 .client
346 .post(&self.endpoint)
347 .with_body(payload)?
348 .send()
349 .await?;
350 check_status(&response)?;
351 let parsed: LoginResponse = serde_json::from_value(response.response_data)
352 .map_err(|e| Error::Serialize(format!("Failed to parse Netcup login: {e}")))?;
353 Ok(parsed.api_session_id)
354 }
355
356 async fn update_dns_records(
357 &self,
358 domain: &str,
359 session: &str,
360 records: Vec<NetcupRecord>,
361 ) -> crate::Result<()> {
362 let payload = Request {
363 action: "updateDnsRecords",
364 param: UpdateDnsRecordsParam {
365 domainname: domain,
366 customernumber: &self.customer_number,
367 apikey: &self.api_key,
368 apisessionid: session,
369 dnsrecordset: DnsRecordSet {
370 dnsrecords: records,
371 },
372 },
373 };
374
375 let response: ResponseMsg = self
376 .client
377 .post(&self.endpoint)
378 .with_body(payload)?
379 .send_with_retry(3)
380 .await?;
381 check_status(&response)?;
382 Ok(())
383 }
384
385 async fn list_all_records(
386 &self,
387 domain: &str,
388 session: &str,
389 ) -> crate::Result<Vec<NetcupRecord>> {
390 let payload = Request {
391 action: "infoDnsRecords",
392 param: InfoDnsRecordsParam {
393 domainname: domain,
394 customernumber: &self.customer_number,
395 apikey: &self.api_key,
396 apisessionid: session,
397 },
398 };
399 let response: ResponseMsg = self
400 .client
401 .post(&self.endpoint)
402 .with_body(payload)?
403 .send()
404 .await?;
405 check_status(&response)?;
406 let parsed: InfoDnsRecordsResponse = serde_json::from_value(response.response_data)
407 .map_err(|e| Error::Serialize(format!("Failed to parse Netcup record list: {e}")))?;
408 Ok(parsed.dnsrecords)
409 }
410
411 #[allow(dead_code)]
412 async fn logout(&self, session: &str) -> crate::Result<()> {
413 let payload = Request {
414 action: "logout",
415 param: LogoutParam {
416 customernumber: &self.customer_number,
417 apikey: &self.api_key,
418 apisessionid: session,
419 },
420 };
421 let response: ResponseMsg = self
422 .client
423 .post(&self.endpoint)
424 .with_body(payload)?
425 .send()
426 .await?;
427 check_status(&response)
428 }
429}
430
431fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
432 for r in records {
433 if r.as_type() != expected {
434 return Err(Error::Api(format!(
435 "RRSet record type mismatch: expected {}, got {}",
436 expected.as_str(),
437 r.as_type().as_str(),
438 )));
439 }
440 }
441 Ok(())
442}
443
444fn check_status(response: &ResponseMsg) -> crate::Result<()> {
445 if response.status == "success" {
446 Ok(())
447 } else {
448 Err(Error::Api(format!(
449 "Netcup API error: status={} code={} short={} long={}",
450 response.status, response.status_code, response.short_message, response.long_message
451 )))
452 }
453}
454
455fn same_payload(a: &NetcupRecord, b: &NetcupRecord) -> bool {
456 a.hostname == b.hostname
457 && a.record_type.eq_ignore_ascii_case(&b.record_type)
458 && a.destination == b.destination
459 && a.priority == b.priority
460}
461
462fn encode_record(record: &DnsRecord, hostname: &str) -> crate::Result<NetcupRecord> {
463 let (record_type, destination, priority) = match record {
464 DnsRecord::A(addr) => ("A", addr.to_string(), String::new()),
465 DnsRecord::AAAA(addr) => ("AAAA", addr.to_string(), String::new()),
466 DnsRecord::CNAME(value) => ("CNAME", value.into_fqdn().into_owned(), String::new()),
467 DnsRecord::NS(value) => ("NS", value.into_fqdn().into_owned(), String::new()),
468 DnsRecord::MX(mx) => (
469 "MX",
470 (&mx.exchange).into_fqdn().into_owned(),
471 mx.priority.to_string(),
472 ),
473 DnsRecord::TXT(value) => ("TXT", value.clone(), String::new()),
474 DnsRecord::SRV(srv) => (
475 "SRV",
476 format!(
477 "{} {} {} {}",
478 srv.priority,
479 srv.weight,
480 srv.port,
481 (&srv.target).into_fqdn().into_owned(),
482 ),
483 srv.priority.to_string(),
484 ),
485 DnsRecord::CAA(caa) => {
486 let (flags, tag, value) = caa.clone().decompose();
487 (
488 "CAA",
489 format!("{} {} \"{}\"", flags, tag, value.replace('"', "\\\"")),
490 String::new(),
491 )
492 }
493 DnsRecord::TLSA(tlsa) => (
494 "TLSA",
495 format!(
496 "{} {} {} {}",
497 u8::from(tlsa.cert_usage),
498 u8::from(tlsa.selector),
499 u8::from(tlsa.matching),
500 tlsa.cert_data
501 .iter()
502 .map(|b| format!("{:02x}", b))
503 .collect::<String>()
504 ),
505 String::new(),
506 ),
507 };
508
509 Ok(NetcupRecord {
510 id: None,
511 hostname: hostname.to_string(),
512 record_type: record_type.to_string(),
513 priority,
514 destination,
515 deleterecord: false,
516 state: String::new(),
517 })
518}
519
520fn decode_record(record_type: DnsRecordType, record: &NetcupRecord) -> crate::Result<DnsRecord> {
521 Ok(match record_type {
522 DnsRecordType::A => {
523 DnsRecord::A(record.destination.parse().map_err(|e: AddrParseError| {
524 Error::Parse(format!(
525 "invalid Netcup A value '{}': {e}",
526 record.destination
527 ))
528 })?)
529 }
530 DnsRecordType::AAAA => {
531 DnsRecord::AAAA(record.destination.parse().map_err(|e: AddrParseError| {
532 Error::Parse(format!(
533 "invalid Netcup AAAA value '{}': {e}",
534 record.destination
535 ))
536 })?)
537 }
538 DnsRecordType::CNAME => {
539 DnsRecord::CNAME(strip_trailing_dot(&record.destination).to_string())
540 }
541 DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(&record.destination).to_string()),
542 DnsRecordType::MX => {
543 let priority: u16 = record.priority.parse().map_err(|e| {
544 Error::Parse(format!(
545 "invalid Netcup MX priority '{}': {e}",
546 record.priority
547 ))
548 })?;
549 DnsRecord::MX(MXRecord {
550 priority,
551 exchange: strip_trailing_dot(&record.destination).to_string(),
552 })
553 }
554 DnsRecordType::TXT => DnsRecord::TXT(record.destination.clone()),
555 DnsRecordType::SRV => parse_srv(record)?,
556 DnsRecordType::TLSA => parse_tlsa(&record.destination)?,
557 DnsRecordType::CAA => parse_caa(&record.destination)?,
558 })
559}
560
561fn parse_srv(record: &NetcupRecord) -> crate::Result<DnsRecord> {
562 let priority: u16 = record.priority.parse().map_err(|e| {
563 Error::Parse(format!(
564 "invalid Netcup SRV priority '{}': {e}",
565 record.priority
566 ))
567 })?;
568 let value = record.destination.as_str();
569 let mut parts = value.split_whitespace();
570 let weight = parts
571 .next()
572 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
573 .parse()
574 .map_err(|e| Error::Parse(format!("invalid SRV weight in '{value}': {e}")))?;
575 let port = parts
576 .next()
577 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
578 .parse()
579 .map_err(|e| Error::Parse(format!("invalid SRV port in '{value}': {e}")))?;
580 let target = parts
581 .next()
582 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?;
583 Ok(DnsRecord::SRV(SRVRecord {
584 priority,
585 weight,
586 port,
587 target: strip_trailing_dot(target).to_string(),
588 }))
589}
590
591fn parse_caa(value: &str) -> crate::Result<DnsRecord> {
592 let mut parts = value.splitn(3, char::is_whitespace);
593 let flags: u8 = parts
594 .next()
595 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
596 .parse()
597 .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
598 let tag = parts
599 .next()
600 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
601 .to_ascii_lowercase();
602 let raw_value = parts
603 .next()
604 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
605 .trim();
606 let unquoted = raw_value
607 .strip_prefix('"')
608 .and_then(|s| s.strip_suffix('"'))
609 .map(|s| s.replace("\\\"", "\""))
610 .unwrap_or_else(|| raw_value.to_string());
611
612 let issuer_critical = flags & 0x80 != 0;
613 match tag.as_str() {
614 "issue" => {
615 let (name, options) = split_caa_value(&unquoted);
616 Ok(DnsRecord::CAA(CAARecord::Issue {
617 issuer_critical,
618 name,
619 options,
620 }))
621 }
622 "issuewild" => {
623 let (name, options) = split_caa_value(&unquoted);
624 Ok(DnsRecord::CAA(CAARecord::IssueWild {
625 issuer_critical,
626 name,
627 options,
628 }))
629 }
630 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
631 issuer_critical,
632 url: unquoted,
633 })),
634 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
635 }
636}