bindcar/
rndc_types.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! RNDC data types for parsing BIND9 output
5//!
6//! This module defines the core data structures used for parsing
7//! RNDC command outputs (showzone, zonestatus, status).
8
9use std::collections::HashMap;
10use std::net::IpAddr;
11
12/// DNS class
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum DnsClass {
15    #[default]
16    IN, // Internet
17    CH, // Chaos
18    HS, // Hesiod
19}
20
21impl DnsClass {
22    pub fn as_str(&self) -> &'static str {
23        match self {
24            DnsClass::IN => "IN",
25            DnsClass::CH => "CH",
26            DnsClass::HS => "HS",
27        }
28    }
29}
30
31/// Zone type
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ZoneType {
34    Primary,
35    Secondary,
36    Stub,
37    Forward,
38    Hint,
39    Mirror,
40    Delegation,
41    Redirect,
42}
43
44impl ZoneType {
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            ZoneType::Primary => "primary",
48            ZoneType::Secondary => "secondary",
49            ZoneType::Stub => "stub",
50            ZoneType::Forward => "forward",
51            ZoneType::Hint => "hint",
52            ZoneType::Mirror => "mirror",
53            ZoneType::Delegation => "delegation-only",
54            ZoneType::Redirect => "redirect",
55        }
56    }
57
58    /// Parse from string, accepting both new and old terminology
59    pub fn parse(s: &str) -> Option<Self> {
60        match s {
61            "primary" | "master" => Some(ZoneType::Primary),
62            "secondary" | "slave" => Some(ZoneType::Secondary),
63            "stub" => Some(ZoneType::Stub),
64            "forward" => Some(ZoneType::Forward),
65            "hint" => Some(ZoneType::Hint),
66            "mirror" => Some(ZoneType::Mirror),
67            "delegation-only" => Some(ZoneType::Delegation),
68            "redirect" => Some(ZoneType::Redirect),
69            _ => None,
70        }
71    }
72}
73
74/// Primary server specification for secondary zones
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct PrimarySpec {
77    pub address: IpAddr,
78    pub port: Option<u16>,
79}
80
81impl PrimarySpec {
82    pub fn new(address: IpAddr) -> Self {
83        Self {
84            address,
85            port: None,
86        }
87    }
88
89    pub fn with_port(address: IpAddr, port: u16) -> Self {
90        Self {
91            address,
92            port: Some(port),
93        }
94    }
95}
96
97/// Forwarder specification for forward zones
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ForwarderSpec {
100    pub address: IpAddr,
101    pub port: Option<u16>,
102    pub tls_config: Option<String>,
103}
104
105impl ForwarderSpec {
106    pub fn new(address: IpAddr) -> Self {
107        Self {
108            address,
109            port: None,
110            tls_config: None,
111        }
112    }
113
114    pub fn with_port(address: IpAddr, port: u16) -> Self {
115        Self {
116            address,
117            port: Some(port),
118            tls_config: None,
119        }
120    }
121
122    pub fn with_tls(address: IpAddr, tls_config: String) -> Self {
123        Self {
124            address,
125            port: None,
126            tls_config: Some(tls_config),
127        }
128    }
129}
130
131/// NOTIFY mode
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum NotifyMode {
134    Yes,
135    No,
136    Explicit,
137    MasterOnly,
138    PrimaryOnly,
139}
140
141impl NotifyMode {
142    pub fn as_str(&self) -> &'static str {
143        match self {
144            NotifyMode::Yes => "yes",
145            NotifyMode::No => "no",
146            NotifyMode::Explicit => "explicit",
147            NotifyMode::MasterOnly => "master-only",
148            NotifyMode::PrimaryOnly => "primary-only",
149        }
150    }
151
152    pub fn parse(s: &str) -> Option<Self> {
153        match s {
154            "yes" => Some(NotifyMode::Yes),
155            "no" => Some(NotifyMode::No),
156            "explicit" => Some(NotifyMode::Explicit),
157            "master-only" => Some(NotifyMode::MasterOnly),
158            "primary-only" => Some(NotifyMode::PrimaryOnly),
159            _ => None,
160        }
161    }
162}
163
164/// Forward mode
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ForwardMode {
167    Only,
168    First,
169}
170
171impl ForwardMode {
172    pub fn as_str(&self) -> &'static str {
173        match self {
174            ForwardMode::Only => "only",
175            ForwardMode::First => "first",
176        }
177    }
178
179    pub fn parse(s: &str) -> Option<Self> {
180        match s {
181            "only" => Some(ForwardMode::Only),
182            "first" => Some(ForwardMode::First),
183            _ => None,
184        }
185    }
186}
187
188/// Auto DNSSEC mode
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum AutoDnssecMode {
191    Off,
192    Maintain,
193    Create,
194}
195
196impl AutoDnssecMode {
197    pub fn as_str(&self) -> &'static str {
198        match self {
199            AutoDnssecMode::Off => "off",
200            AutoDnssecMode::Maintain => "maintain",
201            AutoDnssecMode::Create => "create",
202        }
203    }
204
205    pub fn parse(s: &str) -> Option<Self> {
206        match s {
207            "off" => Some(AutoDnssecMode::Off),
208            "maintain" => Some(AutoDnssecMode::Maintain),
209            "create" => Some(AutoDnssecMode::Create),
210            _ => None,
211        }
212    }
213}
214
215/// Check names mode
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum CheckNamesMode {
218    Fail,
219    Warn,
220    Ignore,
221}
222
223impl CheckNamesMode {
224    pub fn as_str(&self) -> &'static str {
225        match self {
226            CheckNamesMode::Fail => "fail",
227            CheckNamesMode::Warn => "warn",
228            CheckNamesMode::Ignore => "ignore",
229        }
230    }
231
232    pub fn parse(s: &str) -> Option<Self> {
233        match s {
234            "fail" => Some(CheckNamesMode::Fail),
235            "warn" => Some(CheckNamesMode::Warn),
236            "ignore" => Some(CheckNamesMode::Ignore),
237            _ => None,
238        }
239    }
240}
241
242/// Masterfile format
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum MasterfileFormat {
245    Text,
246    Raw,
247    Map,
248}
249
250impl MasterfileFormat {
251    pub fn as_str(&self) -> &'static str {
252        match self {
253            MasterfileFormat::Text => "text",
254            MasterfileFormat::Raw => "raw",
255            MasterfileFormat::Map => "map",
256        }
257    }
258
259    pub fn parse(s: &str) -> Option<Self> {
260        match s {
261            "text" => Some(MasterfileFormat::Text),
262            "raw" => Some(MasterfileFormat::Raw),
263            "map" => Some(MasterfileFormat::Map),
264            _ => None,
265        }
266    }
267}
268
269/// Zone configuration from `rndc showzone`
270#[derive(Debug, Clone, PartialEq)]
271pub struct ZoneConfig {
272    // Core fields
273    pub zone_name: String,
274    pub class: DnsClass,
275    pub zone_type: ZoneType,
276    pub file: Option<String>,
277
278    // Primary/Secondary options
279    pub primaries: Option<Vec<PrimarySpec>>,
280    pub also_notify: Option<Vec<IpAddr>>,
281    pub notify: Option<NotifyMode>,
282
283    // Access Control options
284    pub allow_query: Option<Vec<IpAddr>>,
285    pub allow_transfer: Option<Vec<IpAddr>>,
286    pub allow_update: Option<Vec<IpAddr>>,
287    /// Raw allow-update directive (e.g., "{ key \"name\"; }")
288    /// Used to preserve key-based allow-update when no IPs are specified
289    pub allow_update_raw: Option<String>,
290    pub allow_update_forwarding: Option<Vec<IpAddr>>,
291    pub allow_notify: Option<Vec<IpAddr>>,
292
293    // Transfer Control options
294    pub max_transfer_time_in: Option<u32>,
295    pub max_transfer_time_out: Option<u32>,
296    pub max_transfer_idle_in: Option<u32>,
297    pub max_transfer_idle_out: Option<u32>,
298    pub transfer_source: Option<IpAddr>,
299    pub transfer_source_v6: Option<IpAddr>,
300    pub notify_source: Option<IpAddr>,
301    pub notify_source_v6: Option<IpAddr>,
302
303    // Dynamic Update options
304    /// Raw update-policy directive (complex grammar, kept as raw)
305    pub update_policy: Option<String>,
306    pub journal: Option<String>,
307    pub ixfr_from_differences: Option<bool>,
308
309    // DNSSEC options
310    /// `dnssec-policy` name. Typed (not a `raw_options` entry) per ADR-0001:
311    /// the showzone → modzone round-trip must never drop it by accident —
312    /// verified on BIND 9.18.50, re-issuing a zone config without the
313    /// directive abruptly unsigns the zone at the next reconfig.
314    pub dnssec_policy: Option<String>,
315    pub inline_signing: Option<bool>,
316    pub auto_dnssec: Option<AutoDnssecMode>,
317    pub key_directory: Option<String>,
318    pub sig_validity_interval: Option<u32>,
319    pub dnskey_sig_validity: Option<u32>,
320
321    // Forwarding options
322    pub forward: Option<ForwardMode>,
323    pub forwarders: Option<Vec<ForwarderSpec>>,
324
325    // Zone Maintenance options
326    pub check_names: Option<CheckNamesMode>,
327    pub check_mx: Option<CheckNamesMode>,
328    pub check_integrity: Option<bool>,
329    pub masterfile_format: Option<MasterfileFormat>,
330    pub max_zone_ttl: Option<u32>,
331
332    // Refresh/Retry options
333    pub max_refresh_time: Option<u32>,
334    pub min_refresh_time: Option<u32>,
335    pub max_retry_time: Option<u32>,
336    pub min_retry_time: Option<u32>,
337
338    // Miscellaneous options
339    pub multi_master: Option<bool>,
340    pub request_ixfr: Option<bool>,
341    pub request_expire: Option<bool>,
342
343    // Generic catch-all for unrecognized options
344    /// Raw options that weren't parsed into structured fields
345    /// Key: option name (e.g., "zone-statistics")
346    /// Value: raw value as it appears in config (e.g., "yes" or "{ ... }")
347    pub raw_options: HashMap<String, String>,
348}
349
350impl ZoneConfig {
351    /// Create a new zone configuration
352    pub fn new(zone_name: String, zone_type: ZoneType) -> Self {
353        Self {
354            zone_name,
355            class: DnsClass::IN,
356            zone_type,
357            file: None,
358            primaries: None,
359            also_notify: None,
360            notify: None,
361            allow_query: None,
362            allow_transfer: None,
363            allow_update: None,
364            allow_update_raw: None,
365            allow_update_forwarding: None,
366            allow_notify: None,
367            max_transfer_time_in: None,
368            max_transfer_time_out: None,
369            max_transfer_idle_in: None,
370            max_transfer_idle_out: None,
371            transfer_source: None,
372            transfer_source_v6: None,
373            notify_source: None,
374            notify_source_v6: None,
375            update_policy: None,
376            journal: None,
377            ixfr_from_differences: None,
378            dnssec_policy: None,
379            inline_signing: None,
380            auto_dnssec: None,
381            key_directory: None,
382            sig_validity_interval: None,
383            dnskey_sig_validity: None,
384            forward: None,
385            forwarders: None,
386            check_names: None,
387            check_mx: None,
388            check_integrity: None,
389            masterfile_format: None,
390            max_zone_ttl: None,
391            max_refresh_time: None,
392            min_refresh_time: None,
393            max_retry_time: None,
394            min_retry_time: None,
395            multi_master: None,
396            request_ixfr: None,
397            request_expire: None,
398            raw_options: HashMap::new(),
399        }
400    }
401
402    /// Serialize to RNDC-compatible zone config block
403    ///
404    /// Returns the configuration in the format expected by `rndc modzone`
405    /// and `rndc addzone`, e.g., `{ type primary; file "..."; ... };`
406    pub fn to_rndc_block(&self) -> String {
407        let mut parts = Vec::new();
408
409        // Type is always required
410        parts.push(format!("type {}", self.zone_type.as_str()));
411
412        // File (required for primary zones)
413        if let Some(ref file) = self.file {
414            parts.push(format!(r#"file "{}""#, file));
415        }
416
417        // Primaries (for secondary zones)
418        if let Some(ref primaries) = self.primaries {
419            if !primaries.is_empty() {
420                let primary_list = primaries
421                    .iter()
422                    .map(|p| {
423                        if let Some(port) = p.port {
424                            format!("{} port {}", p.address, port)
425                        } else {
426                            p.address.to_string()
427                        }
428                    })
429                    .collect::<Vec<_>>()
430                    .join("; ");
431                parts.push(format!("primaries {{ {}; }}", primary_list));
432            }
433        }
434
435        // Also-notify
436        if let Some(ref also_notify) = self.also_notify {
437            if !also_notify.is_empty() {
438                let notify_list = also_notify
439                    .iter()
440                    .map(|ip| ip.to_string())
441                    .collect::<Vec<_>>()
442                    .join("; ");
443                parts.push(format!("also-notify {{ {}; }}", notify_list));
444            }
445        }
446
447        // Notify mode
448        if let Some(notify) = self.notify {
449            parts.push(format!("notify {}", notify.as_str()));
450        }
451
452        // Allow-query
453        if let Some(ref allow_query) = self.allow_query {
454            if !allow_query.is_empty() {
455                let query_list = allow_query
456                    .iter()
457                    .map(|ip| ip.to_string())
458                    .collect::<Vec<_>>()
459                    .join("; ");
460                parts.push(format!("allow-query {{ {}; }}", query_list));
461            }
462        }
463
464        // Allow-transfer
465        if let Some(ref allow_transfer) = self.allow_transfer {
466            if !allow_transfer.is_empty() {
467                let transfer_list = allow_transfer
468                    .iter()
469                    .map(|ip| ip.to_string())
470                    .collect::<Vec<_>>()
471                    .join("; ");
472                parts.push(format!("allow-transfer {{ {}; }}", transfer_list));
473            }
474        }
475
476        // Allow-update (prefer raw directive if present, otherwise use IP list)
477        if let Some(ref raw) = self.allow_update_raw {
478            let raw_trimmed = raw.trim_end().trim_end_matches(';').trim();
479            parts.push(format!("allow-update {}", raw_trimmed));
480        } else if let Some(ref allow_update) = self.allow_update {
481            if !allow_update.is_empty() {
482                let update_list = allow_update
483                    .iter()
484                    .map(|ip| ip.to_string())
485                    .collect::<Vec<_>>()
486                    .join("; ");
487                parts.push(format!("allow-update {{ {}; }}", update_list));
488            }
489        }
490
491        // Allow-update-forwarding
492        if let Some(ref allow_update_forwarding) = self.allow_update_forwarding {
493            if !allow_update_forwarding.is_empty() {
494                let list = allow_update_forwarding
495                    .iter()
496                    .map(|ip| ip.to_string())
497                    .collect::<Vec<_>>()
498                    .join("; ");
499                parts.push(format!("allow-update-forwarding {{ {}; }}", list));
500            }
501        }
502
503        // Allow-notify
504        if let Some(ref allow_notify) = self.allow_notify {
505            if !allow_notify.is_empty() {
506                let list = allow_notify
507                    .iter()
508                    .map(|ip| ip.to_string())
509                    .collect::<Vec<_>>()
510                    .join("; ");
511                parts.push(format!("allow-notify {{ {}; }}", list));
512            }
513        }
514
515        // Transfer timeouts
516        if let Some(val) = self.max_transfer_time_in {
517            parts.push(format!("max-transfer-time-in {}", val));
518        }
519        if let Some(val) = self.max_transfer_time_out {
520            parts.push(format!("max-transfer-time-out {}", val));
521        }
522        if let Some(val) = self.max_transfer_idle_in {
523            parts.push(format!("max-transfer-idle-in {}", val));
524        }
525        if let Some(val) = self.max_transfer_idle_out {
526            parts.push(format!("max-transfer-idle-out {}", val));
527        }
528
529        // Transfer sources
530        if let Some(ip) = self.transfer_source {
531            parts.push(format!("transfer-source {}", ip));
532        }
533        if let Some(ip) = self.transfer_source_v6 {
534            parts.push(format!("transfer-source-v6 {}", ip));
535        }
536        if let Some(ip) = self.notify_source {
537            parts.push(format!("notify-source {}", ip));
538        }
539        if let Some(ip) = self.notify_source_v6 {
540            parts.push(format!("notify-source-v6 {}", ip));
541        }
542
543        // Dynamic update options
544        if let Some(ref policy) = self.update_policy {
545            let policy_trimmed = policy.trim_end().trim_end_matches(';').trim();
546            parts.push(format!("update-policy {}", policy_trimmed));
547        }
548        if let Some(ref journal) = self.journal {
549            parts.push(format!(r#"journal "{}""#, journal));
550        }
551        if let Some(val) = self.ixfr_from_differences {
552            parts.push(format!(
553                "ixfr-from-differences {}",
554                if val { "yes" } else { "no" }
555            ));
556        }
557
558        // DNSSEC options
559        if let Some(ref policy) = self.dnssec_policy {
560            parts.push(format!(r#"dnssec-policy "{}""#, policy));
561        }
562        if let Some(val) = self.inline_signing {
563            parts.push(format!("inline-signing {}", if val { "yes" } else { "no" }));
564        }
565        if let Some(mode) = self.auto_dnssec {
566            parts.push(format!("auto-dnssec {}", mode.as_str()));
567        }
568        if let Some(ref dir) = self.key_directory {
569            parts.push(format!(r#"key-directory "{}""#, dir));
570        }
571        if let Some(val) = self.sig_validity_interval {
572            parts.push(format!("sig-validity-interval {}", val));
573        }
574        if let Some(val) = self.dnskey_sig_validity {
575            parts.push(format!("dnskey-sig-validity {}", val));
576        }
577
578        // Forwarding options
579        if let Some(mode) = self.forward {
580            parts.push(format!("forward {}", mode.as_str()));
581        }
582        if let Some(ref forwarders) = self.forwarders {
583            if !forwarders.is_empty() {
584                let forwarder_list = forwarders
585                    .iter()
586                    .map(|f| {
587                        if let Some(ref tls) = f.tls_config {
588                            format!("{} tls {}", f.address, tls)
589                        } else if let Some(port) = f.port {
590                            format!("{} port {}", f.address, port)
591                        } else {
592                            f.address.to_string()
593                        }
594                    })
595                    .collect::<Vec<_>>()
596                    .join("; ");
597                parts.push(format!("forwarders {{ {}; }}", forwarder_list));
598            }
599        }
600
601        // Zone maintenance options
602        if let Some(mode) = self.check_names {
603            parts.push(format!("check-names {}", mode.as_str()));
604        }
605        if let Some(mode) = self.check_mx {
606            parts.push(format!("check-mx {}", mode.as_str()));
607        }
608        if let Some(val) = self.check_integrity {
609            parts.push(format!(
610                "check-integrity {}",
611                if val { "yes" } else { "no" }
612            ));
613        }
614        if let Some(format) = self.masterfile_format {
615            parts.push(format!("masterfile-format {}", format.as_str()));
616        }
617        if let Some(val) = self.max_zone_ttl {
618            parts.push(format!("max-zone-ttl {}", val));
619        }
620
621        // Refresh/Retry options
622        if let Some(val) = self.max_refresh_time {
623            parts.push(format!("max-refresh-time {}", val));
624        }
625        if let Some(val) = self.min_refresh_time {
626            parts.push(format!("min-refresh-time {}", val));
627        }
628        if let Some(val) = self.max_retry_time {
629            parts.push(format!("max-retry-time {}", val));
630        }
631        if let Some(val) = self.min_retry_time {
632            parts.push(format!("min-retry-time {}", val));
633        }
634
635        // Miscellaneous options
636        if let Some(val) = self.multi_master {
637            parts.push(format!("multi-master {}", if val { "yes" } else { "no" }));
638        }
639        if let Some(val) = self.request_ixfr {
640            parts.push(format!("request-ixfr {}", if val { "yes" } else { "no" }));
641        }
642        if let Some(val) = self.request_expire {
643            parts.push(format!("request-expire {}", if val { "yes" } else { "no" }));
644        }
645
646        // Raw options (preserve unknown options verbatim)
647        for (key, value) in &self.raw_options {
648            let value_trimmed = value.trim_end().trim_end_matches(';').trim();
649            parts.push(format!("{} {}", key, value_trimmed));
650        }
651
652        format!("{{ {}; }};", parts.join("; "))
653    }
654}