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