Skip to main content

dns_update/providers/
netcup.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::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}