1use crate::utils::unquote_txt;
13use crate::{
14 DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord,
15 crypto::hmac_sha256,
16 http::{HttpClient, HttpClientBuilder},
17 utils::txt_chunks_to_text,
18};
19use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
20use chrono::Utc;
21use quick_xml::se::to_string as xml_to_string;
22use serde::{Deserialize, Serialize};
23use std::time::Duration;
24
25const DEFAULT_ENDPOINT: &str = "https://dns.api.nifcloud.com";
26const API_VERSION: &str = "2012-12-12N2013-12-16";
27const XMLNS: &str = "https://route53.amazonaws.com/doc/2012-12-12/";
28
29#[derive(Clone)]
30pub struct NifcloudProvider {
31 client: HttpClient,
32 access_key: String,
33 secret_key: String,
34 endpoint: String,
35}
36
37#[derive(Serialize, Debug)]
38#[serde(rename = "ChangeResourceRecordSetsRequest")]
39struct ChangeRequest {
40 #[serde(rename = "@xmlns")]
41 xmlns: &'static str,
42 #[serde(rename = "ChangeBatch")]
43 change_batch: ChangeBatch,
44}
45
46#[derive(Serialize, Debug)]
47struct ChangeBatch {
48 #[serde(rename = "Comment")]
49 comment: String,
50 #[serde(rename = "Changes")]
51 changes: Changes,
52}
53
54#[derive(Serialize, Debug)]
55struct Changes {
56 #[serde(rename = "Change")]
57 change: Vec<Change>,
58}
59
60#[derive(Serialize, Debug)]
61struct Change {
62 #[serde(rename = "Action")]
63 action: &'static str,
64 #[serde(rename = "ResourceRecordSet")]
65 resource_record_set: ResourceRecordSet,
66}
67
68#[derive(Serialize, Debug)]
69struct ResourceRecordSet {
70 #[serde(rename = "Name")]
71 name: String,
72 #[serde(rename = "Type")]
73 record_type: &'static str,
74 #[serde(rename = "TTL")]
75 ttl: u32,
76 #[serde(rename = "ResourceRecords")]
77 resource_records: ResourceRecords,
78}
79
80#[derive(Serialize, Debug)]
81struct ResourceRecords {
82 #[serde(rename = "ResourceRecord")]
83 resource_record: Vec<ResourceRecord>,
84}
85
86#[derive(Serialize, Debug)]
87struct ResourceRecord {
88 #[serde(rename = "Value")]
89 value: String,
90}
91
92#[derive(Deserialize, Debug)]
93struct ChangeResponse {
94 #[serde(rename = "ChangeInfo")]
95 #[allow(dead_code)]
96 change_info: ChangeInfo,
97}
98
99#[derive(Deserialize, Debug)]
100#[allow(dead_code)]
101struct ChangeInfo {
102 #[serde(rename = "Id")]
103 id: String,
104}
105
106#[derive(Deserialize, Debug)]
107struct ErrorResponse {
108 #[serde(rename = "Error", default)]
109 error: NifcloudError,
110}
111
112#[derive(Deserialize, Debug, Default)]
113struct NifcloudError {
114 #[serde(rename = "Code", default)]
115 code: String,
116 #[serde(rename = "Message", default)]
117 message: String,
118}
119
120#[derive(Deserialize, Debug, Default)]
121struct ListResponse {
122 #[serde(rename = "ResourceRecordSets", default)]
123 resource_record_sets: ListedRecordSets,
124 #[serde(rename = "IsTruncated", default)]
125 is_truncated: String,
126 #[serde(rename = "NextRecordName", default)]
127 next_record_name: Option<String>,
128 #[serde(rename = "NextRecordType", default)]
129 next_record_type: Option<String>,
130 #[serde(rename = "NextRecordIdentifier", default)]
131 next_record_identifier: Option<String>,
132}
133
134#[derive(Deserialize, Debug, Default)]
135struct ListedRecordSets {
136 #[serde(rename = "ResourceRecordSet", default)]
137 resource_record_set: Vec<ListedRecordSet>,
138}
139
140#[derive(Deserialize, Debug, Clone)]
141struct ListedRecordSet {
142 #[serde(rename = "Name", default)]
143 name: String,
144 #[serde(rename = "Type", default)]
145 record_type: String,
146 #[serde(rename = "TTL", default)]
147 ttl: u32,
148 #[serde(rename = "SetIdentifier", default)]
149 set_identifier: Option<String>,
150 #[serde(rename = "ResourceRecords", default)]
151 resource_records: ListedResourceRecords,
152}
153
154#[derive(Deserialize, Debug, Default, Clone)]
155struct ListedResourceRecords {
156 #[serde(rename = "ResourceRecord", default)]
157 resource_record: Vec<ListedResourceRecord>,
158}
159
160#[derive(Deserialize, Debug, Clone)]
161struct ListedResourceRecord {
162 #[serde(rename = "Value", default)]
163 value: String,
164}
165
166impl NifcloudProvider {
167 pub(crate) fn new(
168 access_key: impl AsRef<str>,
169 secret_key: impl AsRef<str>,
170 timeout: Option<Duration>,
171 ) -> crate::Result<Self> {
172 let access_key = access_key.as_ref();
173 let secret_key = secret_key.as_ref();
174 if access_key.is_empty() || secret_key.is_empty() {
175 return Err(Error::Api("Nifcloud credentials missing".into()));
176 }
177 let client = HttpClientBuilder::default()
178 .with_header("Accept", "application/xml")
179 .with_timeout(timeout)
180 .build();
181 Ok(Self {
182 client,
183 access_key: access_key.to_string(),
184 secret_key: secret_key.to_string(),
185 endpoint: DEFAULT_ENDPOINT.to_string(),
186 })
187 }
188
189 #[cfg(test)]
190 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
191 Self {
192 endpoint: endpoint.as_ref().to_string(),
193 ..self
194 }
195 }
196
197 fn signed(&self, request: crate::http::HttpRequest) -> crate::http::HttpRequest {
198 let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
199 let mac = hmac_sha256(self.secret_key.as_bytes(), date.as_bytes());
200 let signature = BASE64_STANDARD.encode(&mac);
201 let auth = format!(
202 "NIFTY3-HTTPS NiftyAccessKeyId={},Algorithm=HmacSHA256,Signature={}",
203 self.access_key, signature
204 );
205 request
206 .set_header("Content-Type", "text/xml; charset=utf-8")
207 .with_header("Date", date)
208 .with_header("X-Nifty-Authorization", auth)
209 }
210
211 pub(crate) async fn set_rrset(
212 &self,
213 name: impl IntoFqdn<'_>,
214 record_type: DnsRecordType,
215 ttl: u32,
216 records: Vec<DnsRecord>,
217 origin: impl IntoFqdn<'_>,
218 ) -> crate::Result<()> {
219 let type_str = dns_type_str(record_type)?;
220 check_record_types(record_type, &records)?;
221 let name_str = name.into_name().to_string();
222 let domain = origin.into_name().to_string();
223 let subdomain_name = normalized_record_name(&name_str, &domain);
224
225 let desired = build_values(record_type, &records)?;
226 let existing = self
227 .list_existing(&domain, &subdomain_name, type_str)
228 .await?;
229
230 let mut changes: Vec<Change> = Vec::new();
231 if let Some(prev) = existing.first() {
232 let current_values: Vec<String> = prev
233 .resource_records
234 .resource_record
235 .iter()
236 .map(|r| r.value.clone())
237 .collect();
238 if prev.ttl == ttl && values_equal(¤t_values, &desired) {
239 return Ok(());
240 }
241 changes.push(Change {
242 action: "DELETE",
243 resource_record_set: ResourceRecordSet {
244 name: subdomain_name.clone(),
245 record_type: type_str,
246 ttl: prev.ttl,
247 resource_records: ResourceRecords {
248 resource_record: current_values
249 .into_iter()
250 .map(|value| ResourceRecord { value })
251 .collect(),
252 },
253 },
254 });
255 }
256
257 if !desired.is_empty() {
258 changes.push(Change {
259 action: "CREATE",
260 resource_record_set: ResourceRecordSet {
261 name: subdomain_name,
262 record_type: type_str,
263 ttl,
264 resource_records: ResourceRecords {
265 resource_record: desired
266 .into_iter()
267 .map(|value| ResourceRecord { value })
268 .collect(),
269 },
270 },
271 });
272 }
273
274 if changes.is_empty() {
275 return Ok(());
276 }
277
278 self.send_change(
279 &domain,
280 ChangeRequest {
281 xmlns: XMLNS,
282 change_batch: ChangeBatch {
283 comment: "Managed by dns-update".into(),
284 changes: Changes { change: changes },
285 },
286 },
287 )
288 .await
289 .map(|_| ())
290 }
291
292 pub(crate) async fn add_to_rrset(
293 &self,
294 name: impl IntoFqdn<'_>,
295 record_type: DnsRecordType,
296 ttl: u32,
297 records: Vec<DnsRecord>,
298 origin: impl IntoFqdn<'_>,
299 ) -> crate::Result<()> {
300 if records.is_empty() {
301 return Ok(());
302 }
303 let type_str = dns_type_str(record_type)?;
304 check_record_types(record_type, &records)?;
305 let name_str = name.into_name().to_string();
306 let domain = origin.into_name().to_string();
307 let subdomain_name = normalized_record_name(&name_str, &domain);
308
309 let new_values = build_values(record_type, &records)?;
310 let existing = self
311 .list_existing(&domain, &subdomain_name, type_str)
312 .await?;
313
314 let mut changes: Vec<Change> = Vec::new();
315 let merged: Vec<String>;
316 let merged_ttl: u32;
317
318 if let Some(prev) = existing.first() {
319 let current_values: Vec<String> = prev
320 .resource_records
321 .resource_record
322 .iter()
323 .map(|r| r.value.clone())
324 .collect();
325 let mut combined = current_values.clone();
326 for value in &new_values {
327 if !combined.iter().any(|v| v == value) {
328 combined.push(value.clone());
329 }
330 }
331 if values_equal(¤t_values, &combined) {
332 return Ok(());
333 }
334 changes.push(Change {
335 action: "DELETE",
336 resource_record_set: ResourceRecordSet {
337 name: subdomain_name.clone(),
338 record_type: type_str,
339 ttl: prev.ttl,
340 resource_records: ResourceRecords {
341 resource_record: current_values
342 .into_iter()
343 .map(|value| ResourceRecord { value })
344 .collect(),
345 },
346 },
347 });
348 merged = combined;
349 merged_ttl = prev.ttl;
350 } else {
351 merged = new_values;
352 merged_ttl = ttl;
353 }
354
355 changes.push(Change {
356 action: "CREATE",
357 resource_record_set: ResourceRecordSet {
358 name: subdomain_name,
359 record_type: type_str,
360 ttl: merged_ttl,
361 resource_records: ResourceRecords {
362 resource_record: merged
363 .into_iter()
364 .map(|value| ResourceRecord { value })
365 .collect(),
366 },
367 },
368 });
369
370 self.send_change(
371 &domain,
372 ChangeRequest {
373 xmlns: XMLNS,
374 change_batch: ChangeBatch {
375 comment: "Managed by dns-update".into(),
376 changes: Changes { change: changes },
377 },
378 },
379 )
380 .await
381 .map(|_| ())
382 }
383
384 pub(crate) async fn remove_from_rrset(
385 &self,
386 name: impl IntoFqdn<'_>,
387 record_type: DnsRecordType,
388 records: Vec<DnsRecord>,
389 origin: impl IntoFqdn<'_>,
390 ) -> crate::Result<()> {
391 if records.is_empty() {
392 return Ok(());
393 }
394 let type_str = dns_type_str(record_type)?;
395 check_record_types(record_type, &records)?;
396 let name_str = name.into_name().to_string();
397 let domain = origin.into_name().to_string();
398 let subdomain_name = normalized_record_name(&name_str, &domain);
399
400 let to_remove = build_values(record_type, &records)?;
401 let existing = self
402 .list_existing(&domain, &subdomain_name, type_str)
403 .await?;
404
405 let Some(prev) = existing.first() else {
406 return Ok(());
407 };
408 let current_values: Vec<String> = prev
409 .resource_records
410 .resource_record
411 .iter()
412 .map(|r| r.value.clone())
413 .collect();
414 let remaining: Vec<String> = current_values
415 .iter()
416 .filter(|v| !to_remove.iter().any(|r| r == *v))
417 .cloned()
418 .collect();
419 if remaining.len() == current_values.len() {
420 return Ok(());
421 }
422
423 let mut changes: Vec<Change> = Vec::new();
424 changes.push(Change {
425 action: "DELETE",
426 resource_record_set: ResourceRecordSet {
427 name: subdomain_name.clone(),
428 record_type: type_str,
429 ttl: prev.ttl,
430 resource_records: ResourceRecords {
431 resource_record: current_values
432 .into_iter()
433 .map(|value| ResourceRecord { value })
434 .collect(),
435 },
436 },
437 });
438 if !remaining.is_empty() {
439 changes.push(Change {
440 action: "CREATE",
441 resource_record_set: ResourceRecordSet {
442 name: subdomain_name,
443 record_type: type_str,
444 ttl: prev.ttl,
445 resource_records: ResourceRecords {
446 resource_record: remaining
447 .into_iter()
448 .map(|value| ResourceRecord { value })
449 .collect(),
450 },
451 },
452 });
453 }
454
455 self.send_change(
456 &domain,
457 ChangeRequest {
458 xmlns: XMLNS,
459 change_batch: ChangeBatch {
460 comment: "Managed by dns-update".into(),
461 changes: Changes { change: changes },
462 },
463 },
464 )
465 .await
466 .map(|_| ())
467 }
468
469 pub(crate) async fn list_rrset(
470 &self,
471 name: impl IntoFqdn<'_>,
472 record_type: DnsRecordType,
473 origin: impl IntoFqdn<'_>,
474 ) -> crate::Result<Vec<DnsRecord>> {
475 let type_str = dns_type_str(record_type)?;
476 let name_str = name.into_name().to_string();
477 let domain = origin.into_name().to_string();
478 let subdomain_name = normalized_record_name(&name_str, &domain);
479
480 let existing = self
481 .list_existing(&domain, &subdomain_name, type_str)
482 .await?;
483 let mut out = Vec::new();
484 for rrset in existing {
485 for record in rrset.resource_records.resource_record {
486 out.push(parse_value(record_type, &record.value)?);
487 }
488 }
489 Ok(out)
490 }
491
492 async fn list_existing(
493 &self,
494 domain: &str,
495 subdomain_name: &str,
496 type_str: &str,
497 ) -> crate::Result<Vec<ListedRecordSet>> {
498 let target_name_a = subdomain_name.to_string();
499 let target_name_b = if subdomain_name == "@" {
500 domain.trim_end_matches('.').to_string()
501 } else {
502 format!("{}.{}", subdomain_name, domain.trim_end_matches('.'))
503 };
504
505 let mut out: Vec<ListedRecordSet> = Vec::new();
506 let mut next_name: Option<String> = Some(target_name_b.clone());
507 let mut next_type: Option<String> = Some(type_str.to_string());
508 let mut next_identifier: Option<String> = None;
509
510 loop {
511 let mut query = String::new();
512 if let Some(n) = next_name.as_ref() {
513 query.push_str(&format!("name={}", urlencode(n)));
514 }
515 if let Some(t) = next_type.as_ref() {
516 if !query.is_empty() {
517 query.push('&');
518 }
519 query.push_str(&format!("type={t}"));
520 }
521 if let Some(i) = next_identifier.as_ref() {
522 if !query.is_empty() {
523 query.push('&');
524 }
525 query.push_str(&format!("identifier={}", urlencode(i)));
526 }
527 let url = if query.is_empty() {
528 format!(
529 "{}/{}/hostedzone/{}/rrset",
530 self.endpoint, API_VERSION, domain
531 )
532 } else {
533 format!(
534 "{}/{}/hostedzone/{}/rrset?{}",
535 self.endpoint, API_VERSION, domain, query
536 )
537 };
538 let response = self.signed(self.client.get(url)).send_raw().await?;
539 if response.contains("<Error>") {
540 let parsed: Result<ErrorResponse, _> = quick_xml::de::from_str(&response);
541 if let Ok(err) = parsed {
542 return Err(Error::Api(format!(
543 "Nifcloud error {}: {}",
544 err.error.code, err.error.message
545 )));
546 }
547 return Err(Error::Api(format!("Nifcloud error response: {response}")));
548 }
549 let list: ListResponse = quick_xml::de::from_str(&response)
550 .map_err(|e| Error::Serialize(format!("XML deserialization failed: {e}")))?;
551 for rrset in list.resource_record_sets.resource_record_set {
552 if rrset.set_identifier.is_some() {
553 continue;
554 }
555 if rrset.record_type != type_str {
556 continue;
557 }
558 let candidate = rrset.name.trim_end_matches('.');
559 if candidate == target_name_a.trim_end_matches('.')
560 || candidate == target_name_b.trim_end_matches('.')
561 {
562 out.push(rrset);
563 }
564 }
565 if list.is_truncated.eq_ignore_ascii_case("true")
566 && (list.next_record_name.is_some() || list.next_record_identifier.is_some())
567 {
568 next_name = list.next_record_name;
569 next_type = list.next_record_type;
570 next_identifier = list.next_record_identifier;
571 continue;
572 }
573 break;
574 }
575 Ok(out)
576 }
577
578 async fn send_change(&self, domain: &str, body: ChangeRequest) -> crate::Result<String> {
579 let xml_body = xml_to_string(&body)
580 .map_err(|e| Error::Serialize(format!("XML serialization failed: {e}")))?;
581 let payload = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}", xml_body);
582 let url = format!(
583 "{}/{}/hostedzone/{}/rrset",
584 self.endpoint, API_VERSION, domain
585 );
586 let response = self
587 .signed(self.client.post(url).with_raw_body(payload))
588 .send_raw()
589 .await?;
590 if response.contains("<Error>") {
591 let parsed: Result<ErrorResponse, _> = quick_xml::de::from_str(&response);
592 if let Ok(err) = parsed {
593 return Err(Error::Api(format!(
594 "Nifcloud error {}: {}",
595 err.error.code, err.error.message
596 )));
597 }
598 return Err(Error::Api(format!("Nifcloud error response: {response}")));
599 }
600 let _info: ChangeResponse = quick_xml::de::from_str(&response)
601 .map_err(|e| Error::Serialize(format!("XML deserialization failed: {e}")))?;
602 Ok(response)
603 }
604}
605
606fn urlencode(input: &str) -> String {
607 let mut out = String::with_capacity(input.len());
608 for byte in input.bytes() {
609 match byte {
610 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
611 out.push(byte as char);
612 }
613 _ => {
614 out.push_str(&format!("%{byte:02X}"));
615 }
616 }
617 }
618 out
619}
620
621fn normalized_record_name(name: &str, domain: &str) -> String {
622 let unfqdn = name.trim_end_matches('.');
623 let domain = domain.trim_end_matches('.');
624 if unfqdn == domain {
625 "@".to_string()
626 } else if let Some(prefix) = unfqdn.strip_suffix(&format!(".{}", domain)) {
627 prefix.to_string()
628 } else {
629 unfqdn.to_string()
630 }
631}
632
633fn values_equal(a: &[String], b: &[String]) -> bool {
634 if a.len() != b.len() {
635 return false;
636 }
637 let mut a_sorted: Vec<&String> = a.iter().collect();
638 let mut b_sorted: Vec<&String> = b.iter().collect();
639 a_sorted.sort();
640 b_sorted.sort();
641 a_sorted == b_sorted
642}
643
644fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
645 for record in records {
646 if record.as_type() != expected {
647 return Err(Error::Api(format!(
648 "RRSet record type mismatch: expected {}, got {}",
649 expected.as_str(),
650 record.as_type().as_str(),
651 )));
652 }
653 }
654 Ok(())
655}
656
657fn dns_type_str(record_type: DnsRecordType) -> crate::Result<&'static str> {
658 match record_type {
659 DnsRecordType::A => Ok("A"),
660 DnsRecordType::AAAA => Ok("AAAA"),
661 DnsRecordType::CNAME => Ok("CNAME"),
662 DnsRecordType::NS => Ok("NS"),
663 DnsRecordType::MX => Ok("MX"),
664 DnsRecordType::TXT => Ok("TXT"),
665 DnsRecordType::SRV => Err(Error::Unsupported(
666 "SRV records are not supported by Nifcloud".into(),
667 )),
668 DnsRecordType::CAA => Err(Error::Unsupported(
669 "CAA records are not supported by Nifcloud".into(),
670 )),
671 DnsRecordType::TLSA => Err(Error::Unsupported(
672 "TLSA records are not supported by Nifcloud".into(),
673 )),
674 }
675}
676
677fn build_values(record_type: DnsRecordType, records: &[DnsRecord]) -> crate::Result<Vec<String>> {
678 dns_type_str(record_type)?;
679 let mut out = Vec::with_capacity(records.len());
680 for record in records {
681 out.push(build_value(record)?);
682 }
683 Ok(out)
684}
685
686fn build_value(record: &DnsRecord) -> crate::Result<String> {
687 Ok(match record {
688 DnsRecord::A(addr) => addr.to_string(),
689 DnsRecord::AAAA(addr) => addr.to_string(),
690 DnsRecord::CNAME(target) => target.clone(),
691 DnsRecord::NS(target) => target.clone(),
692 DnsRecord::MX(mx) => format!("{} {}", mx.priority, mx.exchange),
693 DnsRecord::TXT(text) => {
694 let mut out = String::new();
695 txt_chunks_to_text(&mut out, text, " ");
696 out
697 }
698 DnsRecord::SRV(_) => {
699 return Err(Error::Unsupported(
700 "SRV records are not supported by Nifcloud".into(),
701 ));
702 }
703 DnsRecord::CAA(_) => {
704 return Err(Error::Unsupported(
705 "CAA records are not supported by Nifcloud".into(),
706 ));
707 }
708 DnsRecord::TLSA(_) => {
709 return Err(Error::Unsupported(
710 "TLSA records are not supported by Nifcloud".into(),
711 ));
712 }
713 })
714}
715
716fn parse_value(record_type: DnsRecordType, value: &str) -> crate::Result<DnsRecord> {
717 Ok(match record_type {
718 DnsRecordType::A => DnsRecord::A(
719 value
720 .parse()
721 .map_err(|e| Error::Parse(format!("invalid A value: {e}")))?,
722 ),
723 DnsRecordType::AAAA => DnsRecord::AAAA(
724 value
725 .parse()
726 .map_err(|e| Error::Parse(format!("invalid AAAA value: {e}")))?,
727 ),
728 DnsRecordType::CNAME => DnsRecord::CNAME(value.trim_end_matches('.').to_string()),
729 DnsRecordType::NS => DnsRecord::NS(value.trim_end_matches('.').to_string()),
730 DnsRecordType::MX => {
731 let (prio, exchange) = value
732 .split_once(' ')
733 .ok_or_else(|| Error::Parse(format!("invalid MX value: {value}")))?;
734 DnsRecord::MX(MXRecord {
735 priority: prio
736 .trim()
737 .parse()
738 .map_err(|e| Error::Parse(format!("invalid MX priority: {e}")))?,
739 exchange: exchange.trim().trim_end_matches('.').to_string(),
740 })
741 }
742 DnsRecordType::TXT => DnsRecord::TXT(unquote_txt(value)),
743 DnsRecordType::SRV => {
744 return Err(Error::Unsupported(
745 "SRV records are not supported by Nifcloud".into(),
746 ));
747 }
748 DnsRecordType::CAA => {
749 return Err(Error::Unsupported(
750 "CAA records are not supported by Nifcloud".into(),
751 ));
752 }
753 DnsRecordType::TLSA => {
754 return Err(Error::Unsupported(
755 "TLSA records are not supported by Nifcloud".into(),
756 ));
757 }
758 })
759}