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::{
13    DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
14    utils::strip_origin_from_name,
15};
16use serde::{Deserialize, Serialize};
17use std::{
18    sync::{Arc, Mutex},
19    time::{Duration, Instant},
20};
21
22const DEFAULT_ENDPOINT: &str =
23    "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON";
24const SESSION_TTL_SECS: u64 = 10 * 60;
25
26#[derive(Clone)]
27pub struct NetcupProvider {
28    client: HttpClientBuilder,
29    endpoint: String,
30    customer_number: String,
31    api_key: String,
32    api_password: String,
33    session: Arc<Mutex<Option<(String, Instant)>>>,
34}
35
36#[derive(Serialize, Debug)]
37struct Request<P: Serialize> {
38    action: &'static str,
39    param: P,
40}
41
42#[derive(Serialize, Debug)]
43struct LoginParam<'a> {
44    customernumber: &'a str,
45    apikey: &'a str,
46    apipassword: &'a str,
47}
48
49#[derive(Serialize, Debug)]
50struct LogoutParam<'a> {
51    customernumber: &'a str,
52    apikey: &'a str,
53    apisessionid: &'a str,
54}
55
56#[derive(Serialize, Debug)]
57struct InfoDnsRecordsParam<'a> {
58    domainname: &'a str,
59    customernumber: &'a str,
60    apikey: &'a str,
61    apisessionid: &'a str,
62}
63
64#[derive(Serialize, Debug)]
65struct UpdateDnsRecordsParam<'a> {
66    domainname: &'a str,
67    customernumber: &'a str,
68    apikey: &'a str,
69    apisessionid: &'a str,
70    dnsrecordset: DnsRecordSet,
71}
72
73#[derive(Serialize, Debug)]
74struct DnsRecordSet {
75    dnsrecords: Vec<NetcupRecord>,
76}
77
78#[derive(Serialize, Deserialize, Clone, Debug)]
79struct NetcupRecord {
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    id: Option<String>,
82    hostname: String,
83    #[serde(rename = "type")]
84    record_type: String,
85    #[serde(default, skip_serializing_if = "String::is_empty")]
86    priority: String,
87    destination: String,
88    #[serde(default, skip_serializing_if = "is_false")]
89    deleterecord: bool,
90    #[serde(default, skip_serializing_if = "String::is_empty")]
91    state: String,
92}
93
94fn is_false(v: &bool) -> bool {
95    !*v
96}
97
98#[derive(Deserialize, Debug)]
99struct ResponseMsg {
100    #[serde(default)]
101    status: String,
102    #[serde(default, rename = "statuscode")]
103    status_code: i64,
104    #[serde(default, rename = "shortmessage")]
105    short_message: String,
106    #[serde(default, rename = "longmessage")]
107    long_message: String,
108    #[serde(default, rename = "responsedata")]
109    response_data: serde_json::Value,
110}
111
112#[derive(Deserialize, Debug)]
113struct LoginResponse {
114    #[serde(default, rename = "apisessionid")]
115    api_session_id: String,
116}
117
118#[derive(Deserialize, Debug)]
119struct InfoDnsRecordsResponse {
120    #[serde(default)]
121    dnsrecords: Vec<NetcupRecord>,
122}
123
124impl NetcupProvider {
125    pub(crate) fn new(
126        customer_number: impl AsRef<str>,
127        api_key: impl AsRef<str>,
128        api_password: impl AsRef<str>,
129        timeout: Option<Duration>,
130    ) -> Self {
131        let client = HttpClientBuilder::default().with_timeout(timeout);
132        Self {
133            client,
134            endpoint: DEFAULT_ENDPOINT.to_string(),
135            customer_number: customer_number.as_ref().to_string(),
136            api_key: api_key.as_ref().to_string(),
137            api_password: api_password.as_ref().to_string(),
138            session: Arc::new(Mutex::new(None)),
139        }
140    }
141
142    #[cfg(test)]
143    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
144        Self {
145            endpoint: endpoint.as_ref().to_string(),
146            ..self
147        }
148    }
149
150    pub(crate) async fn create(
151        &self,
152        name: impl IntoFqdn<'_>,
153        record: DnsRecord,
154        _ttl: u32,
155        origin: impl IntoFqdn<'_>,
156    ) -> crate::Result<()> {
157        let name = name.into_name().into_owned();
158        let origin = origin.into_name().into_owned();
159        let hostname = strip_origin_from_name(&name, &origin, Some("@"));
160        let payload = encode_record(&record, &hostname)?;
161        let session = self.ensure_session().await?;
162
163        self.update_dns_records(&origin, &session, vec![payload])
164            .await
165    }
166
167    pub(crate) async fn update(
168        &self,
169        name: impl IntoFqdn<'_>,
170        record: DnsRecord,
171        _ttl: u32,
172        origin: impl IntoFqdn<'_>,
173    ) -> crate::Result<()> {
174        let name = name.into_name().into_owned();
175        let origin = origin.into_name().into_owned();
176        let hostname = strip_origin_from_name(&name, &origin, Some("@"));
177        let record_type = record.as_type();
178        let session = self.ensure_session().await?;
179
180        let existing = self
181            .find_record_by_name_type(&origin, &session, &hostname, record_type.as_str())
182            .await?;
183
184        let new = encode_record(&record, &hostname)?;
185        let merged = NetcupRecord {
186            id: existing.id.clone(),
187            hostname: new.hostname,
188            record_type: new.record_type,
189            priority: new.priority,
190            destination: new.destination,
191            deleterecord: false,
192            state: String::new(),
193        };
194
195        self.update_dns_records(&origin, &session, vec![merged])
196            .await
197    }
198
199    pub(crate) async fn delete(
200        &self,
201        name: impl IntoFqdn<'_>,
202        origin: impl IntoFqdn<'_>,
203        record_type: DnsRecordType,
204    ) -> crate::Result<()> {
205        let name = name.into_name().into_owned();
206        let origin = origin.into_name().into_owned();
207        let hostname = strip_origin_from_name(&name, &origin, Some("@"));
208        let session = self.ensure_session().await?;
209
210        let existing = self
211            .find_record_by_name_type(&origin, &session, &hostname, record_type.as_str())
212            .await?;
213
214        let to_delete = NetcupRecord {
215            id: existing.id.clone(),
216            hostname: existing.hostname.clone(),
217            record_type: existing.record_type.clone(),
218            priority: existing.priority.clone(),
219            destination: existing.destination.clone(),
220            deleterecord: true,
221            state: String::new(),
222        };
223
224        self.update_dns_records(&origin, &session, vec![to_delete])
225            .await
226    }
227
228    async fn ensure_session(&self) -> crate::Result<String> {
229        if let Some((ref id, expiry)) = *self.session_lock()?
230            && Instant::now() < expiry
231        {
232            return Ok(id.clone());
233        }
234        let id = self.login().await?;
235        let expiry = Instant::now() + Duration::from_secs(SESSION_TTL_SECS);
236        *self.session_lock()? = Some((id.clone(), expiry));
237        Ok(id)
238    }
239
240    fn session_lock(
241        &self,
242    ) -> crate::Result<std::sync::MutexGuard<'_, Option<(String, Instant)>>> {
243        self.session
244            .lock()
245            .map_err(|_| Error::Client("Netcup session lock poisoned".into()))
246    }
247
248    async fn login(&self) -> crate::Result<String> {
249        let payload = Request {
250            action: "login",
251            param: LoginParam {
252                customernumber: &self.customer_number,
253                apikey: &self.api_key,
254                apipassword: &self.api_password,
255            },
256        };
257        let response: ResponseMsg = self
258            .client
259            .post(&self.endpoint)
260            .with_body(payload)?
261            .send()
262            .await?;
263        check_status(&response)?;
264        let parsed: LoginResponse = serde_json::from_value(response.response_data)
265            .map_err(|e| Error::Serialize(format!("Failed to parse Netcup login: {e}")))?;
266        Ok(parsed.api_session_id)
267    }
268
269    async fn update_dns_records(
270        &self,
271        domain: &str,
272        session: &str,
273        records: Vec<NetcupRecord>,
274    ) -> crate::Result<()> {
275        let payload = Request {
276            action: "updateDnsRecords",
277            param: UpdateDnsRecordsParam {
278                domainname: domain,
279                customernumber: &self.customer_number,
280                apikey: &self.api_key,
281                apisessionid: session,
282                dnsrecordset: DnsRecordSet { dnsrecords: records },
283            },
284        };
285
286        let response: ResponseMsg = self
287            .client
288            .post(&self.endpoint)
289            .with_body(payload)?
290            .send()
291            .await?;
292        check_status(&response)?;
293        Ok(())
294    }
295
296    async fn find_record_by_name_type(
297        &self,
298        domain: &str,
299        session: &str,
300        hostname: &str,
301        record_type: &str,
302    ) -> crate::Result<NetcupRecord> {
303        let payload = Request {
304            action: "infoDnsRecords",
305            param: InfoDnsRecordsParam {
306                domainname: domain,
307                customernumber: &self.customer_number,
308                apikey: &self.api_key,
309                apisessionid: session,
310            },
311        };
312        let response: ResponseMsg = self
313            .client
314            .post(&self.endpoint)
315            .with_body(payload)?
316            .send()
317            .await?;
318        check_status(&response)?;
319        let parsed: InfoDnsRecordsResponse = serde_json::from_value(response.response_data)
320            .map_err(|e| {
321                Error::Serialize(format!("Failed to parse Netcup record list: {e}"))
322            })?;
323
324        parsed
325            .dnsrecords
326            .into_iter()
327            .find(|r| r.hostname == hostname && r.record_type.eq_ignore_ascii_case(record_type))
328            .ok_or_else(|| {
329                Error::Api(format!(
330                    "DNS Record {} of type {} not found in Netcup zone",
331                    hostname, record_type
332                ))
333            })
334    }
335
336    #[allow(dead_code)]
337    async fn logout(&self, session: &str) -> crate::Result<()> {
338        let payload = Request {
339            action: "logout",
340            param: LogoutParam {
341                customernumber: &self.customer_number,
342                apikey: &self.api_key,
343                apisessionid: session,
344            },
345        };
346        let response: ResponseMsg = self
347            .client
348            .post(&self.endpoint)
349            .with_body(payload)?
350            .send()
351            .await?;
352        check_status(&response)
353    }
354}
355
356fn check_status(response: &ResponseMsg) -> crate::Result<()> {
357    if response.status == "success" {
358        Ok(())
359    } else {
360        Err(Error::Api(format!(
361            "Netcup API error: status={} code={} short={} long={}",
362            response.status,
363            response.status_code,
364            response.short_message,
365            response.long_message
366        )))
367    }
368}
369
370fn encode_record(record: &DnsRecord, hostname: &str) -> crate::Result<NetcupRecord> {
371    let (record_type, destination, priority) = match record {
372        DnsRecord::A(addr) => ("A", addr.to_string(), String::new()),
373        DnsRecord::AAAA(addr) => ("AAAA", addr.to_string(), String::new()),
374        DnsRecord::CNAME(value) => ("CNAME", value.clone(), String::new()),
375        DnsRecord::NS(value) => ("NS", value.clone(), String::new()),
376        DnsRecord::MX(mx) => ("MX", mx.exchange.clone(), mx.priority.to_string()),
377        DnsRecord::TXT(value) => ("TXT", value.clone(), String::new()),
378        DnsRecord::SRV(srv) => (
379            "SRV",
380            format!("{} {} {}", srv.weight, srv.port, srv.target),
381            srv.priority.to_string(),
382        ),
383        DnsRecord::CAA(caa) => {
384            let (flags, tag, value) = caa.clone().decompose();
385            (
386                "CAA",
387                format!("{} {} \"{}\"", flags, tag, value.replace('"', "\\\"")),
388                String::new(),
389            )
390        }
391        DnsRecord::TLSA(tlsa) => (
392            "TLSA",
393            format!(
394                "{} {} {} {}",
395                u8::from(tlsa.cert_usage),
396                u8::from(tlsa.selector),
397                u8::from(tlsa.matching),
398                tlsa.cert_data
399                    .iter()
400                    .map(|b| format!("{:02x}", b))
401                    .collect::<String>()
402            ),
403            String::new(),
404        ),
405    };
406
407    Ok(NetcupRecord {
408        id: None,
409        hostname: hostname.to_string(),
410        record_type: record_type.to_string(),
411        priority,
412        destination,
413        deleterecord: false,
414        state: String::new(),
415    })
416}