Skip to main content

dnslib/control_plane/
sync.rs

1//! Record-level sync between two configured DNS servers.
2//!
3//! `dns sync` reads records from a source server, optionally rewrites IP
4//! addresses on A/AAAA records (e.g. external → internal), and writes the
5//! difference to a destination server. It is vendor-neutral: it goes through
6//! the shared `core::dns` traits, so any pair of supported vendors can sync.
7//!
8//! Sync is **additive** — it adds records the destination is missing and
9//! updates record sets whose values differ, but never prunes whole names that
10//! exist only on the destination. It is **dry-run by default**; `--apply`
11//! commits the changes.
12
13use 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
24/// TTL used when a source record reports a TTL of 0 (some vendors do not
25/// expose per-record TTLs).
26const DEFAULT_TTL: u32 = 3600;
27
28/// One record to be written to (or removed from) the destination.
29#[derive(Debug, Clone)]
30struct PlannedRecord {
31    /// Fully-qualified record name.
32    fqdn: String,
33    /// Uppercase record type, e.g. `A`.
34    rtype: String,
35    ttl: u32,
36    record: RecordData,
37}
38
39/// The computed difference for one zone.
40#[derive(Debug, Default)]
41struct Diff {
42    adds: Vec<PlannedRecord>,
43    deletes: Vec<PlannedRecord>,
44    unchanged: usize,
45    /// Destination records whose name+type is absent from the source — left
46    /// untouched because sync is additive.
47    untouched: usize,
48}
49
50/// The plan for one zone, ready to display or apply.
51#[derive(Debug)]
52struct ZonePlan {
53    zone: String,
54    adds: Vec<PlannedRecord>,
55    deletes: Vec<PlannedRecord>,
56    unchanged: usize,
57    untouched: usize,
58    /// Source records that cannot be synced (SOA, DNSSEC, disabled, unknown).
59    skipped: usize,
60}
61
62/// Run a record sync.
63///
64/// `profile` selects a named `[[sync]]` profile from the config; `from`, `to`,
65/// `zones` and `maps` are CLI overrides that take precedence over the profile.
66///
67/// # Errors
68///
69/// Returns an error if the config, servers, zones, or IP mappings cannot be
70/// resolved, or — when `apply` is set — if any record write fails.
71#[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    // Resolve the profile, if one was named.
89    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    // From/to: CLI flag wins, then the profile.
102    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    // IP map: profile entries first, then CLI --map (which overrides).
114    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    // Zones: CLI wins, then the profile, then every zone on the source.
132    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
177/// Build the sync plan for a single zone.
178async 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
217/// Turn a vendor record-list response into syncable [`PlannedRecord`]s,
218/// applying `ip_map` when one is supplied. Returns the records plus the count
219/// of records skipped because they are disabled or not syncable.
220fn 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            // Server-managed records (SOA, DNSSEC) and unknown types cannot be
235            // written through the record API.
236            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
256/// Compute the additive difference between source and destination records.
257///
258/// Records are grouped into sets by `(name, type)`. A set missing on the
259/// destination is added wholesale; a set present on both with differing values
260/// has its missing values added and its stale values removed. Sets that exist
261/// only on the destination are counted as `untouched` and never pruned.
262fn 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    // A record is "unchanged" only when its value AND TTL match the destination;
280    // otherwise it is added (and the stale destination value, if any, deleted)
281    // so source TTLs propagate.
282    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
316/// Apply the planned changes to the destination, reporting per-record outcomes.
317async 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        // Add new values before removing stale ones, to minimise the window in
323        // which a name resolves to nothing.
324        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        // Don't run destructive deletes for a zone whose additions failed —
339        // we might remove the only working copy of a record.
340        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, &params)
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
370/// Rewrite an A/AAAA record's address through the IP map. Other record types
371/// and unmapped addresses pass through unchanged.
372fn 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
386/// Parse a `SRC=DST` IP-mapping spec. Both sides must be IP addresses of the
387/// same family.
388fn 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
408/// A canonical string for a record's data, used to compare record values.
409fn 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
418/// A stable sort key so plan output is deterministic.
419fn sort_key(record: &PlannedRecord) -> (String, String, String) {
420    (
421        record.fqdn.to_lowercase(),
422        record.rtype.clone(),
423        canonical(&record.record),
424    )
425}
426
427/// A compact, human-readable rendering of a record's value.
428fn value_display(record: &RecordData) -> String {
429    record
430        .to_api_params()
431        .into_iter()
432        .skip(1) // drop the leading ("type", ...) param
433        .map(|(_, value)| value)
434        .collect::<Vec<_>>()
435        .join(" ")
436}
437
438/// Print the sync plan as an aligned table.
439fn 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
494/// Print the sync plan as JSON.
495fn 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    // ── apply_ip_map ──────────────────────────────────────────────────────────
556
557    #[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    // ── parse_ip_pair ─────────────────────────────────────────────────────────
615
616    #[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    // ── canonical ─────────────────────────────────────────────────────────────
632
633    #[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    // ── diff_records ──────────────────────────────────────────────────────────
649
650    #[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}