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