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#[allow(clippy::too_many_arguments)]
72pub async fn run_sync(
73 app_config: Option<&AppConfig>,
74 profile: Option<&str>,
75 from: Option<&str>,
76 to: Option<&str>,
77 zones: &[String],
78 maps: &[String],
79 apply: bool,
80 json: bool,
81) -> Result<()> {
82 let Some(cfg) = app_config else {
83 return Err(Error::config(
84 "sync requires a config file defining the source and destination servers",
85 ));
86 };
87
88 let profile = match profile {
90 Some(name) => Some(
91 cfg.sync
92 .iter()
93 .find(|p| p.name.eq_ignore_ascii_case(name))
94 .ok_or_else(|| {
95 Error::config(format!("config does not define a sync profile named '{name}'"))
96 })?,
97 ),
98 None => None,
99 };
100
101 let from_id = from
103 .or_else(|| profile.map(|p| p.from.as_str()))
104 .ok_or_else(|| {
105 Error::parse("sync requires a source server: name a profile or pass --from")
106 })?;
107 let to_id = to
108 .or_else(|| profile.map(|p| p.to.as_str()))
109 .ok_or_else(|| {
110 Error::parse("sync requires a destination server: name a profile or pass --to")
111 })?;
112
113 let mut ip_map: HashMap<IpAddr, IpAddr> = HashMap::new();
115 if let Some(p) = profile {
116 for (src, dst) in &p.ip_map {
117 let (s, d) = parse_ip_pair(&format!("{src}={dst}"))?;
118 ip_map.insert(s, d);
119 }
120 }
121 for spec in maps {
122 let (s, d) = parse_ip_pair(spec)?;
123 ip_map.insert(s, d);
124 }
125
126 let from_server = cfg.selected_server(Some(from_id))?;
127 let to_server = cfg.selected_server(Some(to_id))?;
128 let from_client = VendorClient::from_server(from_server)?;
129 let to_client = VendorClient::from_server(to_server)?;
130
131 let zone_list: Vec<String> = if !zones.is_empty() {
133 zones.to_vec()
134 } else if let Some(p) = profile.filter(|p| !p.zones.is_empty()) {
135 p.zones.clone()
136 } else {
137 const PAGE_SIZE: u32 = 1000;
138 let mut page = 1;
139 let mut names = Vec::new();
140 loop {
141 let value = from_client.list_zones(page, PAGE_SIZE).await?;
142 let batch = extract_zone_names(&value);
143 let batch_len = batch.len();
144 names.extend(batch);
145 if batch_len < PAGE_SIZE as usize {
146 break;
147 }
148 page += 1;
149 }
150 if names.is_empty() {
151 return Err(Error::parse(format!(
152 "no zones found on source server '{from_id}'; specify one with --zone"
153 )));
154 }
155 names
156 };
157
158 let mut plans = Vec::with_capacity(zone_list.len());
159 for zone in &zone_list {
160 plans.push(plan_zone(&from_client, &to_client, zone, &ip_map).await?);
161 }
162
163 if json {
164 render_json(from_id, to_id, &plans, apply)?;
165 } else {
166 render_table(from_id, to_id, &plans, apply);
167 }
168
169 let has_changes = plans.iter().any(|p| !p.adds.is_empty() || !p.deletes.is_empty());
170 if !apply || !has_changes {
171 return Ok(());
172 }
173
174 apply_plans(&to_client, &plans).await
175}
176
177async fn plan_zone(
179 from_client: &VendorClient,
180 to_client: &VendorClient,
181 zone: &str,
182 ip_map: &HashMap<IpAddr, IpAddr>,
183) -> Result<ZonePlan> {
184 let opts = ListRecordsOptions::default();
185
186 let source = from_client
187 .list_records(zone, Some(zone), opts)
188 .await
189 .map_err(|e| Error::parse(format!("source: listing records for zone '{zone}': {e}")))?;
190 let dest = to_client
191 .list_records(zone, Some(zone), opts)
192 .await
193 .map_err(|e| {
194 Error::parse(format!(
195 "destination: listing records for zone '{zone}' \
196 (does the zone exist on the destination?): {e}"
197 ))
198 })?;
199
200 let (source_records, skipped) = collect_records(&source, zone, Some(ip_map));
201 let (dest_records, _) = collect_records(&dest, zone, None);
202
203 let mut diff = diff_records(source_records, dest_records);
204 diff.adds.sort_by_key(sort_key);
205 diff.deletes.sort_by_key(sort_key);
206
207 Ok(ZonePlan {
208 zone: zone.to_string(),
209 adds: diff.adds,
210 deletes: diff.deletes,
211 unchanged: diff.unchanged,
212 untouched: diff.untouched,
213 skipped,
214 })
215}
216
217fn collect_records(
221 response: &ListRecordsResponse,
222 zone: &str,
223 ip_map: Option<&HashMap<IpAddr, IpAddr>>,
224) -> (Vec<PlannedRecord>, usize) {
225 let mut out = Vec::new();
226 let mut skipped = 0;
227
228 for zone_records in &response.zones {
229 for record in &zone_records.records {
230 if record.disabled {
231 skipped += 1;
232 continue;
233 }
234 let Some(AnyRecordData::Writable(rd)) = record.typed() else {
237 skipped += 1;
238 continue;
239 };
240 let rd = match ip_map {
241 Some(map) => apply_ip_map(rd, map),
242 None => rd,
243 };
244 out.push(PlannedRecord {
245 fqdn: resolve_fqdn(&record.name, Some(zone)),
246 rtype: rd.type_name().to_string(),
247 ttl: if record.ttl == 0 { DEFAULT_TTL } else { record.ttl },
248 record: rd,
249 });
250 }
251 }
252
253 (out, skipped)
254}
255
256fn diff_records(source: Vec<PlannedRecord>, dest: Vec<PlannedRecord>) -> Diff {
263 let group = |records: Vec<PlannedRecord>| {
264 let mut groups: HashMap<(String, String), Vec<PlannedRecord>> = HashMap::new();
265 for r in records {
266 groups
267 .entry((r.fqdn.to_lowercase(), r.rtype.clone()))
268 .or_default()
269 .push(r);
270 }
271 groups
272 };
273
274 let source_groups = group(source);
275 let dest_groups = group(dest);
276
277 let mut diff = Diff::default();
278
279 let match_key = |r: &PlannedRecord| (canonical(&r.record), r.ttl);
283
284 for (key, src_recs) in &source_groups {
285 let dest_recs = dest_groups.get(key);
286 let dest_keys: Vec<(String, u32)> = dest_recs
287 .map(|recs| recs.iter().map(match_key).collect())
288 .unwrap_or_default();
289 let src_keys: Vec<(String, u32)> = src_recs.iter().map(match_key).collect();
290
291 for r in src_recs {
292 if dest_keys.contains(&match_key(r)) {
293 diff.unchanged += 1;
294 } else {
295 diff.adds.push(r.clone());
296 }
297 }
298 if let Some(dest_recs) = dest_recs {
299 for r in dest_recs {
300 if !src_keys.contains(&match_key(r)) {
301 diff.deletes.push(r.clone());
302 }
303 }
304 }
305 }
306
307 diff.untouched = dest_groups
308 .iter()
309 .filter(|(key, _)| !source_groups.contains_key(*key))
310 .map(|(_, recs)| recs.len())
311 .sum();
312
313 diff
314}
315
316async fn apply_plans(to_client: &VendorClient, plans: &[ZonePlan]) -> Result<()> {
318 let mut applied = 0;
319 let mut failures = 0;
320
321 for plan in plans {
322 let mut zone_add_failed = false;
325 for rec in &plan.adds {
326 match to_client
327 .add_record(&plan.zone, &rec.fqdn, rec.ttl, &rec.record)
328 .await
329 {
330 Ok(_) => applied += 1,
331 Err(e) => {
332 failures += 1;
333 zone_add_failed = true;
334 eprintln!(" ! add {} {} failed: {e}", rec.fqdn, rec.rtype);
335 }
336 }
337 }
338 if zone_add_failed {
341 eprintln!(
342 " ! skipping removals for zone '{}' because one or more additions failed",
343 plan.zone
344 );
345 continue;
346 }
347 for rec in &plan.deletes {
348 let params = rec.record.to_api_params();
349 match to_client
350 .delete_record(&plan.zone, &rec.fqdn, ¶ms)
351 .await
352 {
353 Ok(_) => applied += 1,
354 Err(e) => {
355 failures += 1;
356 eprintln!(" ! remove {} {} failed: {e}", rec.fqdn, rec.rtype);
357 }
358 }
359 }
360 }
361
362 if failures > 0 {
363 println!("\nApplied {applied} change(s), {failures} failed.");
364 return Err(Error::api(format!("{failures} sync change(s) failed")));
365 }
366 println!("\nApplied {applied} change(s).");
367 Ok(())
368}
369
370fn apply_ip_map(record: RecordData, map: &HashMap<IpAddr, IpAddr>) -> RecordData {
373 match record {
374 RecordData::A { ip } => match map.get(&IpAddr::V4(ip)) {
375 Some(IpAddr::V4(mapped)) => RecordData::A { ip: *mapped },
376 _ => RecordData::A { ip },
377 },
378 RecordData::Aaaa { ip } => match map.get(&IpAddr::V6(ip)) {
379 Some(IpAddr::V6(mapped)) => RecordData::Aaaa { ip: *mapped },
380 _ => RecordData::Aaaa { ip },
381 },
382 other => other,
383 }
384}
385
386fn parse_ip_pair(spec: &str) -> Result<(IpAddr, IpAddr)> {
389 let (src, dst) = spec
390 .split_once('=')
391 .ok_or_else(|| Error::parse(format!("invalid IP mapping '{spec}': expected SRC=DST")))?;
392 let src = src.trim();
393 let dst = dst.trim();
394 let source: IpAddr = src
395 .parse()
396 .map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{src}' is not an IP")))?;
397 let dest: IpAddr = dst
398 .parse()
399 .map_err(|_| Error::parse(format!("invalid IP mapping '{spec}': '{dst}' is not an IP")))?;
400 if source.is_ipv4() != dest.is_ipv4() {
401 return Err(Error::parse(format!(
402 "invalid IP mapping '{spec}': mixes IPv4 and IPv6"
403 )));
404 }
405 Ok((source, dest))
406}
407
408fn canonical(record: &RecordData) -> String {
410 record
411 .to_api_params()
412 .into_iter()
413 .map(|(key, value)| format!("{key}\u{1}{value}"))
414 .collect::<Vec<_>>()
415 .join("\u{2}")
416}
417
418fn sort_key(record: &PlannedRecord) -> (String, String, String) {
420 (
421 record.fqdn.to_lowercase(),
422 record.rtype.clone(),
423 canonical(&record.record),
424 )
425}
426
427fn value_display(record: &RecordData) -> String {
429 record
430 .to_api_params()
431 .into_iter()
432 .skip(1) .map(|(_, value)| value)
434 .collect::<Vec<_>>()
435 .join(" ")
436}
437
438fn render_table(from_id: &str, to_id: &str, plans: &[ZonePlan], apply: bool) {
440 let mode = if apply { "apply" } else { "dry run" };
441 println!("Sync plan: {from_id} -> {to_id} ({mode})");
442
443 let mut adds = 0;
444 let mut deletes = 0;
445 let mut unchanged = 0;
446 let mut skipped = 0;
447 let mut untouched = 0;
448
449 for plan in plans {
450 adds += plan.adds.len();
451 deletes += plan.deletes.len();
452 unchanged += plan.unchanged;
453 skipped += plan.skipped;
454 untouched += plan.untouched;
455
456 if plan.adds.is_empty() && plan.deletes.is_empty() {
457 continue;
458 }
459 println!("\nZone: {}", plan.zone);
460 for rec in &plan.adds {
461 println!(
462 " + {:<28} {:<6} {}",
463 rec.fqdn,
464 rec.rtype,
465 value_display(&rec.record)
466 );
467 }
468 for rec in &plan.deletes {
469 println!(
470 " - {:<28} {:<6} {}",
471 rec.fqdn,
472 rec.rtype,
473 value_display(&rec.record)
474 );
475 }
476 }
477
478 println!(
479 "\n{adds} to add, {deletes} to remove, {unchanged} unchanged, \
480 {skipped} skipped (not syncable)."
481 );
482 if untouched > 0 {
483 println!(
484 "{untouched} destination record(s) absent from the source were left untouched."
485 );
486 }
487 if adds + deletes == 0 {
488 println!("Already in sync — nothing to do.");
489 } else if !apply {
490 println!("Dry run — no changes written. Re-run with --apply to commit.");
491 }
492}
493
494fn render_json(from_id: &str, to_id: &str, plans: &[ZonePlan], apply: bool) -> Result<()> {
496 let rec_json = |rec: &PlannedRecord| {
497 serde_json::json!({
498 "name": rec.fqdn,
499 "type": rec.rtype,
500 "ttl": rec.ttl,
501 "value": value_display(&rec.record),
502 })
503 };
504
505 let zones: Vec<_> = plans
506 .iter()
507 .map(|plan| {
508 serde_json::json!({
509 "zone": plan.zone,
510 "add": plan.adds.iter().map(rec_json).collect::<Vec<_>>(),
511 "remove": plan.deletes.iter().map(rec_json).collect::<Vec<_>>(),
512 "unchanged": plan.unchanged,
513 "untouched": plan.untouched,
514 "skipped": plan.skipped,
515 })
516 })
517 .collect();
518
519 let out = serde_json::json!({
520 "from": from_id,
521 "to": to_id,
522 "applied": apply,
523 "zones": zones,
524 });
525
526 let pretty = serde_json::to_string_pretty(&out)
527 .map_err(|e| Error::parse(format!("could not serialise sync plan: {e}")))?;
528 println!("{pretty}");
529 Ok(())
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use rstest::rstest;
536
537 fn ip_map(pairs: &[(&str, &str)]) -> HashMap<IpAddr, IpAddr> {
538 pairs
539 .iter()
540 .map(|(s, d)| (s.parse().unwrap(), d.parse().unwrap()))
541 .collect()
542 }
543
544 fn a(name: &str, ip: &str) -> PlannedRecord {
545 PlannedRecord {
546 fqdn: name.to_string(),
547 rtype: "A".to_string(),
548 ttl: 3600,
549 record: RecordData::A {
550 ip: ip.parse().unwrap(),
551 },
552 }
553 }
554
555 #[test]
558 fn ip_map_rewrites_mapped_a_record() {
559 let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
560 let mapped = apply_ip_map(
561 RecordData::A {
562 ip: "203.0.113.10".parse().unwrap(),
563 },
564 &map,
565 );
566 match mapped {
567 RecordData::A { ip } => assert_eq!(ip.to_string(), "192.168.1.10"),
568 other => panic!("expected A, got {other:?}"),
569 }
570 }
571
572 #[test]
573 fn ip_map_leaves_unmapped_a_record_untouched() {
574 let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
575 let mapped = apply_ip_map(
576 RecordData::A {
577 ip: "8.8.8.8".parse().unwrap(),
578 },
579 &map,
580 );
581 match mapped {
582 RecordData::A { ip } => assert_eq!(ip.to_string(), "8.8.8.8"),
583 other => panic!("expected A, got {other:?}"),
584 }
585 }
586
587 #[test]
588 fn ip_map_rewrites_mapped_aaaa_record() {
589 let map = ip_map(&[("2001:db8::1", "fd00::1")]);
590 let mapped = apply_ip_map(
591 RecordData::Aaaa {
592 ip: "2001:db8::1".parse().unwrap(),
593 },
594 &map,
595 );
596 match mapped {
597 RecordData::Aaaa { ip } => assert_eq!(ip.to_string(), "fd00::1"),
598 other => panic!("expected AAAA, got {other:?}"),
599 }
600 }
601
602 #[test]
603 fn ip_map_leaves_non_address_records_untouched() {
604 let map = ip_map(&[("203.0.113.10", "192.168.1.10")]);
605 let mapped = apply_ip_map(
606 RecordData::Cname {
607 target: "example.com".to_string(),
608 },
609 &map,
610 );
611 assert!(matches!(mapped, RecordData::Cname { .. }));
612 }
613
614 #[test]
617 fn parse_ip_pair_accepts_valid_pair() {
618 let (s, d) = parse_ip_pair("203.0.113.10 = 192.168.1.10").unwrap();
619 assert_eq!(s.to_string(), "203.0.113.10");
620 assert_eq!(d.to_string(), "192.168.1.10");
621 }
622
623 #[rstest]
624 #[case::missing_separator("203.0.113.10")]
625 #[case::bad_address("203.0.113.10=not-an-ip")]
626 #[case::family_mismatch("203.0.113.10=fd00::1")]
627 fn parse_ip_pair_rejects_bad_input(#[case] spec: &str) {
628 assert!(parse_ip_pair(spec).is_err());
629 }
630
631 #[test]
634 fn canonical_equal_for_same_value_differs_for_others() {
635 let one = RecordData::A {
636 ip: "1.2.3.4".parse().unwrap(),
637 };
638 let same = RecordData::A {
639 ip: "1.2.3.4".parse().unwrap(),
640 };
641 let other = RecordData::A {
642 ip: "1.2.3.5".parse().unwrap(),
643 };
644 assert_eq!(canonical(&one), canonical(&same));
645 assert_ne!(canonical(&one), canonical(&other));
646 }
647
648 #[test]
651 fn diff_adds_record_set_missing_on_destination() {
652 let diff = diff_records(vec![a("www.example.com", "1.1.1.1")], vec![]);
653 assert_eq!(diff.adds.len(), 1);
654 assert_eq!(diff.deletes.len(), 0);
655 assert_eq!(diff.unchanged, 0);
656 }
657
658 #[test]
659 fn diff_updates_changed_value_with_add_and_remove() {
660 let diff = diff_records(
661 vec![a("www.example.com", "2.2.2.2")],
662 vec![a("www.example.com", "1.1.1.1")],
663 );
664 assert_eq!(diff.adds.len(), 1);
665 assert_eq!(diff.deletes.len(), 1);
666 assert_eq!(diff.unchanged, 0);
667 match &diff.adds[0].record {
668 RecordData::A { ip } => assert_eq!(ip.to_string(), "2.2.2.2"),
669 other => panic!("expected A, got {other:?}"),
670 }
671 }
672
673 #[test]
674 fn diff_reports_identical_records_as_unchanged() {
675 let diff = diff_records(
676 vec![a("www.example.com", "1.1.1.1")],
677 vec![a("www.example.com", "1.1.1.1")],
678 );
679 assert_eq!(diff.adds.len(), 0);
680 assert_eq!(diff.deletes.len(), 0);
681 assert_eq!(diff.unchanged, 1);
682 }
683
684 #[test]
685 fn diff_treats_ttl_difference_as_update() {
686 let mut src = a("www.example.com", "1.1.1.1");
687 src.ttl = 300;
688 let mut dst = a("www.example.com", "1.1.1.1");
689 dst.ttl = 3600;
690 let diff = diff_records(vec![src], vec![dst]);
691 assert_eq!(diff.adds.len(), 1);
692 assert_eq!(diff.deletes.len(), 1);
693 assert_eq!(diff.unchanged, 0);
694 assert_eq!(diff.adds[0].ttl, 300);
695 }
696
697 #[test]
698 fn diff_never_prunes_destination_only_names() {
699 let diff = diff_records(
700 vec![a("a.example.com", "1.1.1.1")],
701 vec![
702 a("a.example.com", "1.1.1.1"),
703 a("b.example.com", "2.2.2.2"),
704 ],
705 );
706 assert_eq!(diff.adds.len(), 0);
707 assert_eq!(diff.deletes.len(), 0);
708 assert_eq!(diff.unchanged, 1);
709 assert_eq!(diff.untouched, 1);
710 }
711}