1use std::collections::HashMap;
14use std::net::IpAddr;
15
16use crate::control_plane::config::AppConfig;
17use crate::core::dns::records::RecordData;
18use crate::core::dns::records::query::{extract_zone_names, resolve_fqdn};
19use crate::core::dns::responses::{AnyRecordData, ListRecordsResponse};
20use crate::core::dns::service::{ListRecordsOptions, RecordWrite, ZoneRead};
21use crate::core::error::{Error, Result};
22use crate::vendors::runtime::VendorClient;
23
24const DEFAULT_TTL: u32 = 3600;
27
28#[derive(Debug, Clone)]
30struct PlannedRecord {
31 fqdn: String,
33 rtype: String,
35 ttl: u32,
36 record: RecordData,
37}
38
39#[derive(Debug, Default)]
41struct Diff {
42 adds: Vec<PlannedRecord>,
43 deletes: Vec<PlannedRecord>,
44 unchanged: usize,
45 untouched: usize,
48}
49
50#[derive(Debug)]
52struct ZonePlan {
53 zone: String,
54 adds: Vec<PlannedRecord>,
55 deletes: Vec<PlannedRecord>,
56 unchanged: usize,
57 untouched: usize,
58 skipped: usize,
60}
61
62#[derive(Debug, Clone, serde::Serialize)]
63pub struct SyncApplySummary {
64 pub applied: usize,
65 pub failures: usize,
66}
67
68#[allow(clippy::too_many_arguments)]
78pub async fn run_sync(
79 app_config: Option<&AppConfig>,
80 profile: Option<&str>,
81 from: Option<&str>,
82 to: Option<&str>,
83 zones: &[String],
84 maps: &[String],
85 apply: bool,
86 json: bool,
87) -> Result<()> {
88 let (from_id, to_id, plans) =
89 build_sync_plan(app_config, profile, from, to, zones, maps).await?;
90
91 if json {
92 let out = sync_plan_json(&from_id, &to_id, &plans, apply);
93 let pretty = serde_json::to_string_pretty(&out)
94 .map_err(|e| Error::parse(format!("could not serialise sync plan: {e}")))?;
95 println!("{pretty}");
96 } else {
97 render_table(&from_id, &to_id, &plans, apply);
98 }
99
100 let has_changes = plans
101 .iter()
102 .any(|p| !p.adds.is_empty() || !p.deletes.is_empty());
103 if !apply || !has_changes {
104 return Ok(());
105 }
106
107 let summary = apply_plans(
108 &VendorClient::from_server(
109 app_config
110 .and_then(|cfg| cfg.selected_server(Some(&to_id)).ok())
111 .ok_or_else(|| Error::config("sync destination server disappeared"))?,
112 )?,
113 &plans,
114 )
115 .await?;
116 println!("\nApplied {} change(s).", summary.applied);
117 Ok(())
118}
119
120#[allow(clippy::too_many_arguments)]
121pub async fn run_sync_json(
122 app_config: Option<&AppConfig>,
123 profile: Option<&str>,
124 from: Option<&str>,
125 to: Option<&str>,
126 zones: &[String],
127 maps: &[String],
128 apply: bool,
129) -> Result<serde_json::Value> {
130 let (from_id, to_id, plans) =
131 build_sync_plan(app_config, profile, from, to, zones, maps).await?;
132 let mut out = sync_plan_json(&from_id, &to_id, &plans, apply);
133
134 let has_changes = plans
135 .iter()
136 .any(|p| !p.adds.is_empty() || !p.deletes.is_empty());
137 if apply && has_changes {
138 let cfg = app_config.expect("build_sync_plan already required config");
139 let to_server = cfg.selected_server(Some(&to_id))?;
140 let summary = apply_plans(&VendorClient::from_server(to_server)?, &plans).await?;
141 out["apply_summary"] = serde_json::to_value(summary)
142 .map_err(|e| Error::parse(format!("could not serialise sync summary: {e}")))?;
143 }
144
145 Ok(out)
146}
147
148#[allow(clippy::too_many_arguments)]
149async fn build_sync_plan(
150 app_config: Option<&AppConfig>,
151 profile: Option<&str>,
152 from: Option<&str>,
153 to: Option<&str>,
154 zones: &[String],
155 maps: &[String],
156) -> Result<(String, String, Vec<ZonePlan>)> {
157 let Some(cfg) = app_config else {
158 return Err(Error::config(
159 "sync requires a config file defining the source and destination servers",
160 ));
161 };
162
163 let profile = match profile {
165 Some(name) => Some(
166 cfg.sync
167 .iter()
168 .find(|p| p.name.eq_ignore_ascii_case(name))
169 .ok_or_else(|| {
170 Error::config(format!(
171 "config does not define a sync profile named '{name}'"
172 ))
173 })?,
174 ),
175 None => None,
176 };
177
178 let from_id = from
180 .or_else(|| profile.map(|p| p.from.as_str()))
181 .ok_or_else(|| {
182 Error::parse("sync requires a source server: name a profile or pass --from")
183 })?;
184 let to_id = to
185 .or_else(|| profile.map(|p| p.to.as_str()))
186 .ok_or_else(|| {
187 Error::parse("sync requires a destination server: name a profile or pass --to")
188 })?;
189
190 let mut ip_map: HashMap<IpAddr, IpAddr> = HashMap::new();
192 if let Some(p) = profile {
193 for (src, dst) in &p.ip_map {
194 let (s, d) = parse_ip_pair(&format!("{src}={dst}"))?;
195 ip_map.insert(s, d);
196 }
197 }
198 for spec in maps {
199 let (s, d) = parse_ip_pair(spec)?;
200 ip_map.insert(s, d);
201 }
202
203 let from_server = cfg.selected_server(Some(from_id))?;
204 let to_server = cfg.selected_server(Some(to_id))?;
205 let from_client = VendorClient::from_server(from_server)?;
206 let to_client = VendorClient::from_server(to_server)?;
207
208 let zone_list: Vec<String> = if !zones.is_empty() {
210 zones.to_vec()
211 } else if let Some(p) = profile.filter(|p| !p.zones.is_empty()) {
212 p.zones.clone()
213 } else {
214 const PAGE_SIZE: u32 = 1000;
215 let mut page = 1;
216 let mut names = Vec::new();
217 loop {
218 let value = from_client.list_zones(page, PAGE_SIZE).await?;
219 let batch = extract_zone_names(&value);
220 let batch_len = batch.len();
221 names.extend(batch);
222 if batch_len < PAGE_SIZE as usize {
223 break;
224 }
225 page += 1;
226 }
227 if names.is_empty() {
228 return Err(Error::parse(format!(
229 "no zones found on source server '{from_id}'; specify one with --zone"
230 )));
231 }
232 names
233 };
234
235 let mut plans = Vec::with_capacity(zone_list.len());
236 for zone in &zone_list {
237 plans.push(plan_zone(&from_client, &to_client, zone, &ip_map).await?);
238 }
239
240 Ok((from_id.to_string(), to_id.to_string(), plans))
241}
242
243async fn plan_zone(
245 from_client: &VendorClient,
246 to_client: &VendorClient,
247 zone: &str,
248 ip_map: &HashMap<IpAddr, IpAddr>,
249) -> Result<ZonePlan> {
250 plan_zone_with_clients(from_client, to_client, zone, ip_map).await
251}
252
253async fn plan_zone_with_clients<F, T>(
254 from_client: &F,
255 to_client: &T,
256 zone: &str,
257 ip_map: &HashMap<IpAddr, IpAddr>,
258) -> Result<ZonePlan>
259where
260 F: ZoneRead + ?Sized,
261 T: ZoneRead + ?Sized,
262{
263 let opts = ListRecordsOptions {
264 all_subdomains: true,
265 ..ListRecordsOptions::default()
266 };
267
268 let source = from_client
269 .list_records(zone, Some(zone), opts)
270 .await
271 .map_err(|e| Error::parse(format!("source: listing records for zone '{zone}': {e}")))?;
272 let dest = to_client
273 .list_records(zone, Some(zone), opts)
274 .await
275 .map_err(|e| {
276 Error::parse(format!(
277 "destination: listing records for zone '{zone}' \
278 (does the zone exist on the destination?): {e}"
279 ))
280 })?;
281
282 let (source_records, skipped) = collect_records(&source, zone, Some(ip_map));
283 let (dest_records, _) = collect_records(&dest, zone, None);
284
285 let mut diff = diff_records(source_records, dest_records);
286 diff.adds.sort_by_key(sort_key);
287 diff.deletes.sort_by_key(sort_key);
288
289 Ok(ZonePlan {
290 zone: zone.to_string(),
291 adds: diff.adds,
292 deletes: diff.deletes,
293 unchanged: diff.unchanged,
294 untouched: diff.untouched,
295 skipped,
296 })
297}
298
299fn collect_records(
303 response: &ListRecordsResponse,
304 zone: &str,
305 ip_map: Option<&HashMap<IpAddr, IpAddr>>,
306) -> (Vec<PlannedRecord>, usize) {
307 let mut out = Vec::new();
308 let mut skipped = 0;
309
310 for zone_records in &response.zones {
311 for record in &zone_records.records {
312 if record.disabled {
313 skipped += 1;
314 continue;
315 }
316 let Some(AnyRecordData::Writable(rd)) = record.typed() else {
319 skipped += 1;
320 continue;
321 };
322 let rd = match ip_map {
323 Some(map) => apply_ip_map(rd, map),
324 None => rd,
325 };
326 let fqdn = resolve_fqdn(&record.name, Some(zone));
327 if fqdn.eq_ignore_ascii_case(zone) && rd.type_name() == "NS" {
328 skipped += 1;
329 continue;
330 }
331 out.push(PlannedRecord {
332 fqdn,
333 rtype: rd.type_name().to_string(),
334 ttl: if record.ttl == 0 {
335 DEFAULT_TTL
336 } else {
337 record.ttl
338 },
339 record: rd,
340 });
341 }
342 }
343
344 (out, skipped)
345}
346
347fn diff_records(source: Vec<PlannedRecord>, dest: Vec<PlannedRecord>) -> Diff {
354 let group = |records: Vec<PlannedRecord>| {
355 let mut groups: HashMap<(String, String), Vec<PlannedRecord>> = HashMap::new();
356 for r in records {
357 groups
358 .entry((r.fqdn.to_lowercase(), r.rtype.clone()))
359 .or_default()
360 .push(r);
361 }
362 groups
363 };
364
365 let source_groups = group(source);
366 let dest_groups = group(dest);
367
368 let mut diff = Diff::default();
369
370 let match_key = |r: &PlannedRecord| (canonical(&r.record), r.ttl);
374
375 for (key, src_recs) in &source_groups {
376 let dest_recs = dest_groups.get(key);
377 let dest_keys: Vec<(String, u32)> = dest_recs
378 .map(|recs| recs.iter().map(match_key).collect())
379 .unwrap_or_default();
380 let src_keys: Vec<(String, u32)> = src_recs.iter().map(match_key).collect();
381
382 for r in src_recs {
383 if dest_keys.contains(&match_key(r)) {
384 diff.unchanged += 1;
385 } else {
386 diff.adds.push(r.clone());
387 }
388 }
389 if let Some(dest_recs) = dest_recs {
390 for r in dest_recs {
391 if !src_keys.contains(&match_key(r)) {
392 diff.deletes.push(r.clone());
393 }
394 }
395 }
396 }
397
398 diff.untouched = dest_groups
399 .iter()
400 .filter(|(key, _)| !source_groups.contains_key(*key))
401 .map(|(_, recs)| recs.len())
402 .sum();
403
404 diff
405}
406
407async fn apply_plans(to_client: &VendorClient, plans: &[ZonePlan]) -> Result<SyncApplySummary> {
409 apply_plans_with_client(to_client, plans).await
410}
411
412async fn apply_plans_with_client<C>(to_client: &C, plans: &[ZonePlan]) -> Result<SyncApplySummary>
413where
414 C: RecordWrite + ?Sized,
415{
416 let mut applied = 0;
417 let mut failures = 0;
418
419 for plan in plans {
420 let mut zone_add_failed = false;
423 for rec in &plan.adds {
424 match to_client
425 .add_record(&plan.zone, &rec.fqdn, rec.ttl, &rec.record)
426 .await
427 {
428 Ok(_) => applied += 1,
429 Err(e) => {
430 failures += 1;
431 zone_add_failed = true;
432 eprintln!(" ! add {} {} failed: {e}", rec.fqdn, rec.rtype);
433 }
434 }
435 }
436 if zone_add_failed {
439 eprintln!(
440 " ! skipping removals for zone '{}' because one or more additions failed",
441 plan.zone
442 );
443 continue;
444 }
445 for rec in &plan.deletes {
446 let params = rec.record.to_api_params();
447 match to_client
448 .delete_record(&plan.zone, &rec.fqdn, ¶ms)
449 .await
450 {
451 Ok(_) => applied += 1,
452 Err(e) => {
453 failures += 1;
454 eprintln!(" ! remove {} {} failed: {e}", rec.fqdn, rec.rtype);
455 }
456 }
457 }
458 }
459
460 if failures > 0 {
461 return Err(Error::api(format!("{failures} sync change(s) failed")));
462 }
463 Ok(SyncApplySummary { applied, failures })
464}
465
466fn apply_ip_map(record: RecordData, map: &HashMap<IpAddr, IpAddr>) -> RecordData {
469 match record {
470 RecordData::A { ip } => match map.get(&IpAddr::V4(ip)) {
471 Some(IpAddr::V4(mapped)) => RecordData::A { ip: *mapped },
472 _ => RecordData::A { ip },
473 },
474 RecordData::Aaaa { ip } => match map.get(&IpAddr::V6(ip)) {
475 Some(IpAddr::V6(mapped)) => RecordData::Aaaa { ip: *mapped },
476 _ => RecordData::Aaaa { ip },
477 },
478 other => other,
479 }
480}
481
482fn parse_ip_pair(spec: &str) -> Result<(IpAddr, IpAddr)> {
485 let (src, dst) = spec
486 .split_once('=')
487 .ok_or_else(|| Error::parse(format!("invalid IP mapping '{spec}': expected SRC=DST")))?;
488 let src = src.trim();
489 let dst = dst.trim();
490 let source: IpAddr = src
491 .parse()
492 .map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{src}' is not an IP")))?;
493 let dest: IpAddr = dst
494 .parse()
495 .map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{dst}' is not an IP")))?;
496 if source.is_ipv4() != dest.is_ipv4() {
497 return Err(Error::parse(format!(
498 "invalid IP mapping '{spec}': mixes IPv4 and IPv6"
499 )));
500 }
501 Ok((source, dest))
502}
503
504fn canonical(record: &RecordData) -> String {
506 record
507 .to_api_params()
508 .into_iter()
509 .map(|(key, value)| format!("{key}\u{1}{value}"))
510 .collect::<Vec<_>>()
511 .join("\u{2}")
512}
513
514fn sort_key(record: &PlannedRecord) -> (String, String, String) {
516 (
517 record.fqdn.to_lowercase(),
518 record.rtype.clone(),
519 canonical(&record.record),
520 )
521}
522
523fn value_display(record: &RecordData) -> String {
525 record
526 .to_api_params()
527 .into_iter()
528 .skip(1) .map(|(_, value)| value)
530 .collect::<Vec<_>>()
531 .join(" ")
532}
533
534fn render_table(from_id: &str, to_id: &str, plans: &[ZonePlan], apply: bool) {
536 let mode = if apply { "apply" } else { "dry run" };
537 println!("Sync plan: {from_id} -> {to_id} ({mode})");
538
539 let mut adds = 0;
540 let mut deletes = 0;
541 let mut unchanged = 0;
542 let mut skipped = 0;
543 let mut untouched = 0;
544
545 for plan in plans {
546 adds += plan.adds.len();
547 deletes += plan.deletes.len();
548 unchanged += plan.unchanged;
549 skipped += plan.skipped;
550 untouched += plan.untouched;
551
552 if plan.adds.is_empty() && plan.deletes.is_empty() {
553 continue;
554 }
555 println!("\nZone: {}", plan.zone);
556 for rec in &plan.adds {
557 println!(
558 " + {:<28} {:<6} {}",
559 rec.fqdn,
560 rec.rtype,
561 value_display(&rec.record)
562 );
563 }
564 for rec in &plan.deletes {
565 println!(
566 " - {:<28} {:<6} {}",
567 rec.fqdn,
568 rec.rtype,
569 value_display(&rec.record)
570 );
571 }
572 }
573
574 println!(
575 "\n{adds} to add, {deletes} to remove, {unchanged} unchanged, \
576 {skipped} skipped (not syncable)."
577 );
578 if untouched > 0 {
579 println!("{untouched} destination record(s) absent from the source were left untouched.");
580 }
581 if adds + deletes == 0 {
582 println!("Already in sync — nothing to do.");
583 } else if !apply {
584 println!("Dry run — no changes written. Re-run with --apply to commit.");
585 }
586}
587
588fn sync_plan_json(
590 from_id: &str,
591 to_id: &str,
592 plans: &[ZonePlan],
593 apply: bool,
594) -> serde_json::Value {
595 let rec_json = |rec: &PlannedRecord| {
596 serde_json::json!({
597 "name": rec.fqdn,
598 "type": rec.rtype,
599 "ttl": rec.ttl,
600 "value": value_display(&rec.record),
601 })
602 };
603
604 let zones: Vec<_> = plans
605 .iter()
606 .map(|plan| {
607 serde_json::json!({
608 "zone": plan.zone,
609 "add": plan.adds.iter().map(rec_json).collect::<Vec<_>>(),
610 "remove": plan.deletes.iter().map(rec_json).collect::<Vec<_>>(),
611 "unchanged": plan.unchanged,
612 "untouched": plan.untouched,
613 "skipped": plan.skipped,
614 })
615 })
616 .collect();
617
618 serde_json::json!({
619 "from": from_id,
620 "to": to_id,
621 "applied": apply,
622 "zones": zones,
623 })
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use crate::core::dns::responses::{ZoneInfo, ZoneRecord, ZoneRecords};
630 use rstest::rstest;
631 use serde_json::{Value, json};
632 use std::sync::{Arc, Mutex};
633
634 fn ip_map(pairs: &[(&str, &str)]) -> HashMap<IpAddr, IpAddr> {
635 pairs
636 .iter()
637 .map(|(s, d)| (s.parse().unwrap(), d.parse().unwrap()))
638 .collect()
639 }
640
641 fn a(name: &str, ip: &str) -> PlannedRecord {
642 PlannedRecord {
643 fqdn: name.to_string(),
644 rtype: "A".to_string(),
645 ttl: 3600,
646 record: RecordData::A {
647 ip: ip.parse().unwrap(),
648 },
649 }
650 }
651
652 fn zone_info(name: &str) -> ZoneInfo {
653 ZoneInfo {
654 id: Some(name.to_string()),
655 name: name.to_string(),
656 zone_type: "Primary".to_string(),
657 disabled: false,
658 dnssec_status: None,
659 }
660 }
661
662 fn zone_record(name: &str, record_type: &str, ttl: u32, data: Value) -> ZoneRecord {
663 let mut record = ZoneRecord {
664 name: name.to_string(),
665 record_type: record_type.to_string(),
666 ttl,
667 disabled: false,
668 comments: String::new(),
669 expiry_ttl: 0,
670 data,
671 parsed: None,
672 };
673 record.parsed = record.typed();
674 record
675 }
676
677 fn sync_test_response(zone: &str, records: Vec<ZoneRecord>) -> ListRecordsResponse {
678 ListRecordsResponse {
679 zones: vec![ZoneRecords {
680 zone: zone_info(zone),
681 records,
682 }],
683 }
684 }
685
686 #[derive(Clone)]
687 struct FakeZoneRead {
688 response: ListRecordsResponse,
689 calls: Arc<Mutex<Vec<(String, Option<String>, ListRecordsOptions)>>>,
690 }
691
692 impl FakeZoneRead {
693 fn new(response: ListRecordsResponse) -> Self {
694 Self {
695 response,
696 calls: Arc::new(Mutex::new(Vec::new())),
697 }
698 }
699 }
700
701 impl ZoneRead for FakeZoneRead {
702 async fn list_zones(&self, _page: u32, _per_page: u32) -> Result<Value> {
703 Ok(json!({ "response": { "zones": [] } }))
704 }
705
706 async fn list_records(
707 &self,
708 domain: &str,
709 zone: Option<&str>,
710 options: ListRecordsOptions,
711 ) -> Result<ListRecordsResponse> {
712 self.calls.lock().unwrap().push((
713 domain.to_string(),
714 zone.map(ToOwned::to_owned),
715 options,
716 ));
717 Ok(self.response.clone())
718 }
719 }
720
721 #[derive(Default)]
722 struct FakeRecordWrite {
723 adds: Mutex<Vec<(String, String, u32, RecordData)>>,
724 deletes: Mutex<Vec<(String, String, Vec<(String, String)>)>>,
725 }
726
727 impl RecordWrite for FakeRecordWrite {
728 async fn add_record(
729 &self,
730 zone: &str,
731 domain: &str,
732 ttl: u32,
733 record: &RecordData,
734 ) -> Result<Value> {
735 self.adds.lock().unwrap().push((
736 zone.to_string(),
737 domain.to_string(),
738 ttl,
739 record.clone(),
740 ));
741 Ok(json!({ "status": "ok" }))
742 }
743
744 async fn delete_record(
745 &self,
746 zone: &str,
747 domain: &str,
748 type_params: &[(&str, String)],
749 ) -> Result<Value> {
750 self.deletes.lock().unwrap().push((
751 zone.to_string(),
752 domain.to_string(),
753 type_params
754 .iter()
755 .map(|(key, value)| ((*key).to_string(), value.clone()))
756 .collect(),
757 ));
758 Ok(json!({ "status": "ok" }))
759 }
760 }
761
762 #[test]
765 fn ip_map_rewrites_mapped_a_record() {
766 let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
767 let mapped = apply_ip_map(
768 RecordData::A {
769 ip: "203.0.113.10".parse().unwrap(),
770 },
771 &map,
772 );
773 match mapped {
774 RecordData::A { ip } => assert_eq!(ip.to_string(), "192.168.1.10"),
775 other => panic!("expected A, got {other:?}"),
776 }
777 }
778
779 #[test]
780 fn ip_map_leaves_unmapped_a_record_untouched() {
781 let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
782 let mapped = apply_ip_map(
783 RecordData::A {
784 ip: "8.8.8.8".parse().unwrap(),
785 },
786 &map,
787 );
788 match mapped {
789 RecordData::A { ip } => assert_eq!(ip.to_string(), "8.8.8.8"),
790 other => panic!("expected A, got {other:?}"),
791 }
792 }
793
794 #[test]
795 fn ip_map_rewrites_mapped_aaaa_record() {
796 let map = ip_map(&[("2001:db8::1", "fd00::1")]);
797 let mapped = apply_ip_map(
798 RecordData::Aaaa {
799 ip: "2001:db8::1".parse().unwrap(),
800 },
801 &map,
802 );
803 match mapped {
804 RecordData::Aaaa { ip } => assert_eq!(ip.to_string(), "fd00::1"),
805 other => panic!("expected AAAA, got {other:?}"),
806 }
807 }
808
809 #[test]
810 fn ip_map_leaves_non_address_records_untouched() {
811 let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
812 let mapped = apply_ip_map(
813 RecordData::Cname {
814 target: "example.com".to_string(),
815 },
816 &map,
817 );
818 assert!(matches!(mapped, RecordData::Cname { .. }));
819 }
820
821 #[tokio::test]
824 async fn plan_zone_lists_entire_zone_and_includes_child_records() {
825 let zone = "dnsync-sync-test.example";
826 let source = FakeZoneRead::new(sync_test_response(
827 zone,
828 vec![
829 zone_record(zone, "SOA", 3600, json!({})),
830 zone_record(zone, "NS", 3600, json!({ "nameServer": "dns1.hankin.io" })),
831 zone_record(
832 &format!("www.{zone}"),
833 "A",
834 3600,
835 json!({ "ipAddress": "203.0.113.10" }),
836 ),
837 zone_record(
838 &format!("api.{zone}"),
839 "CNAME",
840 3600,
841 json!({ "cname": format!("www.{zone}") }),
842 ),
843 ],
844 ));
845 let dest = FakeZoneRead::new(sync_test_response(
846 zone,
847 vec![
848 zone_record(zone, "SOA", 3600, json!({})),
849 zone_record(zone, "NS", 3600, json!({ "nameServer": "dns2.hankin.io" })),
850 ],
851 ));
852
853 let plan = plan_zone_with_clients(&source, &dest, zone, &HashMap::new())
854 .await
855 .unwrap();
856
857 assert!(source.calls.lock().unwrap()[0].2.all_subdomains);
858 assert!(dest.calls.lock().unwrap()[0].2.all_subdomains);
859 assert_eq!(plan.adds.len(), 2);
860 assert!(plan.adds.iter().any(|r| {
861 r.fqdn == format!("www.{zone}")
862 && r.rtype == "A"
863 && value_display(&r.record) == "203.0.113.10"
864 }));
865 assert!(plan.adds.iter().any(|r| {
866 r.fqdn == format!("api.{zone}")
867 && r.rtype == "CNAME"
868 && value_display(&r.record) == format!("www.{zone}")
869 }));
870 assert_eq!(plan.skipped, 2);
871 }
872
873 #[tokio::test]
874 async fn apply_writes_missing_child_records_to_destination() {
875 let zone = "dnsync-sync-test.example";
876 let writer = FakeRecordWrite::default();
877 let plan = ZonePlan {
878 zone: zone.to_string(),
879 adds: vec![
880 PlannedRecord {
881 fqdn: format!("www.{zone}"),
882 rtype: "A".to_string(),
883 ttl: 3600,
884 record: RecordData::A {
885 ip: "203.0.113.10".parse().unwrap(),
886 },
887 },
888 PlannedRecord {
889 fqdn: format!("api.{zone}"),
890 rtype: "CNAME".to_string(),
891 ttl: 3600,
892 record: RecordData::Cname {
893 target: format!("www.{zone}"),
894 },
895 },
896 ],
897 deletes: vec![],
898 unchanged: 0,
899 untouched: 0,
900 skipped: 0,
901 };
902
903 apply_plans_with_client(&writer, &[plan]).await.unwrap();
904
905 let adds = writer.adds.lock().unwrap();
906 assert_eq!(adds.len(), 2);
907 assert_eq!(adds[0].0, zone);
908 assert_eq!(adds[0].1, format!("www.{zone}"));
909 assert!(matches!(adds[0].3, RecordData::A { .. }));
910 assert_eq!(adds[1].1, format!("api.{zone}"));
911 assert!(matches!(adds[1].3, RecordData::Cname { .. }));
912 }
913
914 #[tokio::test]
915 async fn plan_zone_applies_ip_mapping_to_child_address_records() {
916 let zone = "dnsync-sync-test.example";
917 let source = FakeZoneRead::new(sync_test_response(
918 zone,
919 vec![zone_record(
920 &format!("www.{zone}"),
921 "A",
922 3600,
923 json!({ "ipAddress": "203.0.113.10" }),
924 )],
925 ));
926 let dest = FakeZoneRead::new(sync_test_response(zone, vec![]));
927 let map = ip_map(&[("203.0.113.10", "192.0.2.10")]);
928
929 let plan = plan_zone_with_clients(&source, &dest, zone, &map)
930 .await
931 .unwrap();
932
933 assert_eq!(plan.adds.len(), 1);
934 assert_eq!(value_display(&plan.adds[0].record), "192.0.2.10");
935 }
936
937 #[test]
940 fn parse_ip_pair_accepts_valid_pair() {
941 let (s, d) = parse_ip_pair("203.0.113.10 = 192.168.1.10").unwrap();
942 assert_eq!(s.to_string(), "203.0.113.10");
943 assert_eq!(d.to_string(), "192.168.1.10");
944 }
945
946 #[rstest]
947 #[case::missing_separator("203.0.113.10")]
948 #[case::bad_address("203.0.113.10=not-an-ip")]
949 #[case::family_mismatch("203.0.113.10=fd00::1")]
950 fn parse_ip_pair_rejects_bad_input(#[case] spec: &str) {
951 assert!(parse_ip_pair(spec).is_err());
952 }
953
954 #[test]
957 fn canonical_equal_for_same_value_differs_for_others() {
958 let one = RecordData::A {
959 ip: "1.2.3.4".parse().unwrap(),
960 };
961 let same = RecordData::A {
962 ip: "1.2.3.4".parse().unwrap(),
963 };
964 let other = RecordData::A {
965 ip: "1.2.3.5".parse().unwrap(),
966 };
967 assert_eq!(canonical(&one), canonical(&same));
968 assert_ne!(canonical(&one), canonical(&other));
969 }
970
971 #[test]
974 fn diff_adds_record_set_missing_on_destination() {
975 let diff = diff_records(vec![a("www.example.com", "1.1.1.1")], vec![]);
976 assert_eq!(diff.adds.len(), 1);
977 assert_eq!(diff.deletes.len(), 0);
978 assert_eq!(diff.unchanged, 0);
979 }
980
981 #[test]
982 fn diff_updates_changed_value_with_add_and_remove() {
983 let diff = diff_records(
984 vec![a("www.example.com", "2.2.2.2")],
985 vec![a("www.example.com", "1.1.1.1")],
986 );
987 assert_eq!(diff.adds.len(), 1);
988 assert_eq!(diff.deletes.len(), 1);
989 assert_eq!(diff.unchanged, 0);
990 match &diff.adds[0].record {
991 RecordData::A { ip } => assert_eq!(ip.to_string(), "2.2.2.2"),
992 other => panic!("expected A, got {other:?}"),
993 }
994 }
995
996 #[test]
997 fn diff_reports_identical_records_as_unchanged() {
998 let diff = diff_records(
999 vec![a("www.example.com", "1.1.1.1")],
1000 vec![a("www.example.com", "1.1.1.1")],
1001 );
1002 assert_eq!(diff.adds.len(), 0);
1003 assert_eq!(diff.deletes.len(), 0);
1004 assert_eq!(diff.unchanged, 1);
1005 }
1006
1007 #[test]
1008 fn diff_treats_ttl_difference_as_update() {
1009 let mut src = a("www.example.com", "1.1.1.1");
1010 src.ttl = 300;
1011 let mut dst = a("www.example.com", "1.1.1.1");
1012 dst.ttl = 3600;
1013 let diff = diff_records(vec![src], vec![dst]);
1014 assert_eq!(diff.adds.len(), 1);
1015 assert_eq!(diff.deletes.len(), 1);
1016 assert_eq!(diff.unchanged, 0);
1017 assert_eq!(diff.adds[0].ttl, 300);
1018 }
1019
1020 #[test]
1021 fn diff_never_prunes_destination_only_names() {
1022 let diff = diff_records(
1023 vec![a("a.example.com", "1.1.1.1")],
1024 vec![a("a.example.com", "1.1.1.1"), a("b.example.com", "2.2.2.2")],
1025 );
1026 assert_eq!(diff.adds.len(), 0);
1027 assert_eq!(diff.deletes.len(), 0);
1028 assert_eq!(diff.unchanged, 1);
1029 assert_eq!(diff.untouched, 1);
1030 }
1031}