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.priority,
489 srv.weight,
490 srv.port,
491 ensure_trailing_dot(&srv.target),
492 ),
493 srv.priority.to_string(),
494 ),
495 DnsRecord::CAA(caa) => {
496 let (flags, tag, value) = caa.clone().decompose();
497 (
498 "CAA",
499 format!("{} {} \"{}\"", flags, tag, value.replace('"', "\\\"")),
500 String::new(),
501 )
502 }
503 DnsRecord::TLSA(tlsa) => (
504 "TLSA",
505 format!(
506 "{} {} {} {}",
507 u8::from(tlsa.cert_usage),
508 u8::from(tlsa.selector),
509 u8::from(tlsa.matching),
510 tlsa.cert_data
511 .iter()
512 .map(|b| format!("{:02x}", b))
513 .collect::<String>()
514 ),
515 String::new(),
516 ),
517 };
518
519 Ok(NetcupRecord {
520 id: None,
521 hostname: hostname.to_string(),
522 record_type: record_type.to_string(),
523 priority,
524 destination,
525 deleterecord: false,
526 state: String::new(),
527 })
528}
529
530fn decode_record(record_type: DnsRecordType, record: &NetcupRecord) -> crate::Result<DnsRecord> {
531 Ok(match record_type {
532 DnsRecordType::A => {
533 DnsRecord::A(record.destination.parse().map_err(|e: AddrParseError| {
534 Error::Parse(format!(
535 "invalid Netcup A value '{}': {e}",
536 record.destination
537 ))
538 })?)
539 }
540 DnsRecordType::AAAA => {
541 DnsRecord::AAAA(record.destination.parse().map_err(|e: AddrParseError| {
542 Error::Parse(format!(
543 "invalid Netcup AAAA value '{}': {e}",
544 record.destination
545 ))
546 })?)
547 }
548 DnsRecordType::CNAME => DnsRecord::CNAME(strip_trailing_dot(&record.destination)),
549 DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(&record.destination)),
550 DnsRecordType::MX => {
551 let priority: u16 = record.priority.parse().map_err(|e| {
552 Error::Parse(format!(
553 "invalid Netcup MX priority '{}': {e}",
554 record.priority
555 ))
556 })?;
557 DnsRecord::MX(MXRecord {
558 priority,
559 exchange: strip_trailing_dot(&record.destination),
560 })
561 }
562 DnsRecordType::TXT => DnsRecord::TXT(record.destination.clone()),
563 DnsRecordType::SRV => parse_srv(record)?,
564 DnsRecordType::TLSA => parse_tlsa(&record.destination)?,
565 DnsRecordType::CAA => parse_caa(&record.destination)?,
566 })
567}
568
569fn parse_srv(record: &NetcupRecord) -> crate::Result<DnsRecord> {
570 let priority: u16 = record.priority.parse().map_err(|e| {
571 Error::Parse(format!(
572 "invalid Netcup SRV priority '{}': {e}",
573 record.priority
574 ))
575 })?;
576 let value = record.destination.as_str();
577 let mut parts = value.split_whitespace();
578 let weight = parts
579 .next()
580 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
581 .parse()
582 .map_err(|e| Error::Parse(format!("invalid SRV weight in '{value}': {e}")))?;
583 let port = parts
584 .next()
585 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
586 .parse()
587 .map_err(|e| Error::Parse(format!("invalid SRV port in '{value}': {e}")))?;
588 let target = parts
589 .next()
590 .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?;
591 Ok(DnsRecord::SRV(SRVRecord {
592 priority,
593 weight,
594 port,
595 target: strip_trailing_dot(target),
596 }))
597}
598
599fn parse_tlsa(value: &str) -> crate::Result<DnsRecord> {
600 let mut parts = value.split_whitespace();
601 let usage_raw: u8 = parts
602 .next()
603 .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
604 .parse()
605 .map_err(|e| Error::Parse(format!("invalid TLSA usage in '{value}': {e}")))?;
606 let selector_raw: u8 = parts
607 .next()
608 .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
609 .parse()
610 .map_err(|e| Error::Parse(format!("invalid TLSA selector in '{value}': {e}")))?;
611 let matching_raw: u8 = parts
612 .next()
613 .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
614 .parse()
615 .map_err(|e| Error::Parse(format!("invalid TLSA matching in '{value}': {e}")))?;
616 let hex = parts
617 .next()
618 .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?;
619 Ok(DnsRecord::TLSA(TLSARecord {
620 cert_usage: tlsa_cert_usage_from_u8(usage_raw)?,
621 selector: tlsa_selector_from_u8(selector_raw)?,
622 matching: tlsa_matching_from_u8(matching_raw)?,
623 cert_data: decode_hex(hex)?,
624 }))
625}
626
627fn parse_caa(value: &str) -> crate::Result<DnsRecord> {
628 let mut parts = value.splitn(3, char::is_whitespace);
629 let flags: u8 = parts
630 .next()
631 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
632 .parse()
633 .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
634 let tag = parts
635 .next()
636 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
637 .to_ascii_lowercase();
638 let raw_value = parts
639 .next()
640 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
641 .trim();
642 let unquoted = raw_value
643 .strip_prefix('"')
644 .and_then(|s| s.strip_suffix('"'))
645 .map(|s| s.replace("\\\"", "\""))
646 .unwrap_or_else(|| raw_value.to_string());
647
648 let issuer_critical = flags & 0x80 != 0;
649 match tag.as_str() {
650 "issue" => {
651 let (name, options) = parse_caa_kv(&unquoted);
652 Ok(DnsRecord::CAA(CAARecord::Issue {
653 issuer_critical,
654 name,
655 options,
656 }))
657 }
658 "issuewild" => {
659 let (name, options) = parse_caa_kv(&unquoted);
660 Ok(DnsRecord::CAA(CAARecord::IssueWild {
661 issuer_critical,
662 name,
663 options,
664 }))
665 }
666 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
667 issuer_critical,
668 url: unquoted,
669 })),
670 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
671 }
672}
673
674fn parse_caa_kv(value: &str) -> (Option<String>, Vec<KeyValue>) {
675 let mut parts = value.split(';').map(str::trim);
676 let name_part = parts.next().unwrap_or("").trim().to_string();
677 let name = if name_part.is_empty() {
678 None
679 } else {
680 Some(name_part)
681 };
682 let options = parts
683 .filter(|p| !p.is_empty())
684 .map(|p| match p.split_once('=') {
685 Some((k, v)) => KeyValue {
686 key: k.trim().to_string(),
687 value: v.trim().to_string(),
688 },
689 None => KeyValue {
690 key: p.trim().to_string(),
691 value: String::new(),
692 },
693 })
694 .collect();
695 (name, options)
696}
697
698fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
699 if !hex.len().is_multiple_of(2) {
700 return Err(Error::Parse(format!("invalid hex string: {hex}")));
701 }
702 (0..hex.len())
703 .step_by(2)
704 .map(|i| {
705 u8::from_str_radix(&hex[i..i + 2], 16)
706 .map_err(|e| Error::Parse(format!("invalid hex byte: {e}")))
707 })
708 .collect()
709}
710
711fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
712 Ok(match value {
713 0 => TlsaCertUsage::PkixTa,
714 1 => TlsaCertUsage::PkixEe,
715 2 => TlsaCertUsage::DaneTa,
716 3 => TlsaCertUsage::DaneEe,
717 255 => TlsaCertUsage::Private,
718 _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {value}"))),
719 })
720}
721
722fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
723 Ok(match value {
724 0 => TlsaSelector::Full,
725 1 => TlsaSelector::Spki,
726 255 => TlsaSelector::Private,
727 _ => return Err(Error::Parse(format!("unknown TLSA selector: {value}"))),
728 })
729}
730
731fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
732 Ok(match value {
733 0 => TlsaMatching::Raw,
734 1 => TlsaMatching::Sha256,
735 2 => TlsaMatching::Sha512,
736 255 => TlsaMatching::Private,
737 _ => return Err(Error::Parse(format!("unknown TLSA matching: {value}"))),
738 })
739}