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    pub inline_signing: Option<bool>,
311    pub auto_dnssec: Option<AutoDnssecMode>,
312    pub key_directory: Option<String>,
313    pub sig_validity_interval: Option<u32>,
314    pub dnskey_sig_validity: Option<u32>,
315
316    // Forwarding options
317    pub forward: Option<ForwardMode>,
318    pub forwarders: Option<Vec<ForwarderSpec>>,
319
320    // Zone Maintenance options
321    pub check_names: Option<CheckNamesMode>,
322    pub check_mx: Option<CheckNamesMode>,
323    pub check_integrity: Option<bool>,
324    pub masterfile_format: Option<MasterfileFormat>,
325    pub max_zone_ttl: Option<u32>,
326
327    // Refresh/Retry options
328    pub max_refresh_time: Option<u32>,
329    pub min_refresh_time: Option<u32>,
330    pub max_retry_time: Option<u32>,
331    pub min_retry_time: Option<u32>,
332
333    // Miscellaneous options
334    pub multi_master: Option<bool>,
335    pub request_ixfr: Option<bool>,
336    pub request_expire: Option<bool>,
337
338    // Generic catch-all for unrecognized options
339    /// Raw options that weren't parsed into structured fields
340    /// Key: option name (e.g., "zone-statistics")
341    /// Value: raw value as it appears in config (e.g., "yes" or "{ ... }")
342    pub raw_options: HashMap<String, String>,
343}
344
345impl ZoneConfig {
346    /// Create a new zone configuration
347    pub fn new(zone_name: String, zone_type: ZoneType) -> Self {
348        Self {
349            zone_name,
350            class: DnsClass::IN,
351            zone_type,
352            file: None,
353            primaries: None,
354            also_notify: None,
355            notify: None,
356            allow_query: None,
357            allow_transfer: None,
358            allow_update: None,
359            allow_update_raw: None,
360            allow_update_forwarding: None,
361            allow_notify: None,
362            max_transfer_time_in: None,
363            max_transfer_time_out: None,
364            max_transfer_idle_in: None,
365            max_transfer_idle_out: None,
366            transfer_source: None,
367            transfer_source_v6: None,
368            notify_source: None,
369            notify_source_v6: None,
370            update_policy: None,
371            journal: None,
372            ixfr_from_differences: None,
373            inline_signing: None,
374            auto_dnssec: None,
375            key_directory: None,
376            sig_validity_interval: None,
377            dnskey_sig_validity: None,
378            forward: None,
379            forwarders: None,
380            check_names: None,
381            check_mx: None,
382            check_integrity: None,
383            masterfile_format: None,
384            max_zone_ttl: None,
385            max_refresh_time: None,
386            min_refresh_time: None,
387            max_retry_time: None,
388            min_retry_time: None,
389            multi_master: None,
390            request_ixfr: None,
391            request_expire: None,
392            raw_options: HashMap::new(),
393        }
394    }
395
396    /// Serialize to RNDC-compatible zone config block
397    ///
398    /// Returns the configuration in the format expected by `rndc modzone`
399    /// and `rndc addzone`, e.g., `{ type primary; file "..."; ... };`
400    pub fn to_rndc_block(&self) -> String {
401        let mut parts = Vec::new();
402
403        // Type is always required
404        parts.push(format!("type {}", self.zone_type.as_str()));
405
406        // File (required for primary zones)
407        if let Some(ref file) = self.file {
408            parts.push(format!(r#"file "{}""#, file));
409        }
410
411        // Primaries (for secondary zones)
412        if let Some(ref primaries) = self.primaries {
413            if !primaries.is_empty() {
414                let primary_list = primaries
415                    .iter()
416                    .map(|p| {
417                        if let Some(port) = p.port {
418                            format!("{} port {}", p.address, port)
419                        } else {
420                            p.address.to_string()
421                        }
422                    })
423                    .collect::<Vec<_>>()
424                    .join("; ");
425                parts.push(format!("primaries {{ {}; }}", primary_list));
426            }
427        }
428
429        // Also-notify
430        if let Some(ref also_notify) = self.also_notify {
431            if !also_notify.is_empty() {
432                let notify_list = also_notify
433                    .iter()
434                    .map(|ip| ip.to_string())
435                    .collect::<Vec<_>>()
436                    .join("; ");
437                parts.push(format!("also-notify {{ {}; }}", notify_list));
438            }
439        }
440
441        // Notify mode
442        if let Some(notify) = self.notify {
443            parts.push(format!("notify {}", notify.as_str()));
444        }
445
446        // Allow-query
447        if let Some(ref allow_query) = self.allow_query {
448            if !allow_query.is_empty() {
449                let query_list = allow_query
450                    .iter()
451                    .map(|ip| ip.to_string())
452                    .collect::<Vec<_>>()
453                    .join("; ");
454                parts.push(format!("allow-query {{ {}; }}", query_list));
455            }
456        }
457
458        // Allow-transfer
459        if let Some(ref allow_transfer) = self.allow_transfer {
460            if !allow_transfer.is_empty() {
461                let transfer_list = allow_transfer
462                    .iter()
463                    .map(|ip| ip.to_string())
464                    .collect::<Vec<_>>()
465                    .join("; ");
466                parts.push(format!("allow-transfer {{ {}; }}", transfer_list));
467            }
468        }
469
470        // Allow-update (prefer raw directive if present, otherwise use IP list)
471        if let Some(ref raw) = self.allow_update_raw {
472            let raw_trimmed = raw.trim_end().trim_end_matches(';').trim();
473            parts.push(format!("allow-update {}", raw_trimmed));
474        } else if let Some(ref allow_update) = self.allow_update {
475            if !allow_update.is_empty() {
476                let update_list = allow_update
477                    .iter()
478                    .map(|ip| ip.to_string())
479                    .collect::<Vec<_>>()
480                    .join("; ");
481                parts.push(format!("allow-update {{ {}; }}", update_list));
482            }
483        }
484
485        // Allow-update-forwarding
486        if let Some(ref allow_update_forwarding) = self.allow_update_forwarding {
487            if !allow_update_forwarding.is_empty() {
488                let list = allow_update_forwarding
489                    .iter()
490                    .map(|ip| ip.to_string())
491                    .collect::<Vec<_>>()
492                    .join("; ");
493                parts.push(format!("allow-update-forwarding {{ {}; }}", list));
494            }
495        }
496
497        // Allow-notify
498        if let Some(ref allow_notify) = self.allow_notify {
499            if !allow_notify.is_empty() {
500                let list = allow_notify
501                    .iter()
502                    .map(|ip| ip.to_string())
503                    .collect::<Vec<_>>()
504                    .join("; ");
505                parts.push(format!("allow-notify {{ {}; }}", list));
506            }
507        }
508
509        // Transfer timeouts
510        if let Some(val) = self.max_transfer_time_in {
511            parts.push(format!("max-transfer-time-in {}", val));
512        }
513        if let Some(val) = self.max_transfer_time_out {
514            parts.push(format!("max-transfer-time-out {}", val));
515        }
516        if let Some(val) = self.max_transfer_idle_in {
517            parts.push(format!("max-transfer-idle-in {}", val));
518        }
519        if let Some(val) = self.max_transfer_idle_out {
520            parts.push(format!("max-transfer-idle-out {}", val));
521        }
522
523        // Transfer sources
524        if let Some(ip) = self.transfer_source {
525            parts.push(format!("transfer-source {}", ip));
526        }
527        if let Some(ip) = self.transfer_source_v6 {
528            parts.push(format!("transfer-source-v6 {}", ip));
529        }
530        if let Some(ip) = self.notify_source {
531            parts.push(format!("notify-source {}", ip));
532        }
533        if let Some(ip) = self.notify_source_v6 {
534            parts.push(format!("notify-source-v6 {}", ip));
535        }
536
537        // Dynamic update options
538        if let Some(ref policy) = self.update_policy {
539            let policy_trimmed = policy.trim_end().trim_end_matches(';').trim();
540            parts.push(format!("update-policy {}", policy_trimmed));
541        }
542        if let Some(ref journal) = self.journal {
543            parts.push(format!(r#"journal "{}""#, journal));
544        }
545        if let Some(val) = self.ixfr_from_differences {
546            parts.push(format!(
547                "ixfr-from-differences {}",
548                if val { "yes" } else { "no" }
549            ));
550        }
551
552        // DNSSEC options
553        if let Some(val) = self.inline_signing {
554            parts.push(format!("inline-signing {}", if val { "yes" } else { "no" }));
555        }
556        if let Some(mode) = self.auto_dnssec {
557            parts.push(format!("auto-dnssec {}", mode.as_str()));
558        }
559        if let Some(ref dir) = self.key_directory {
560            parts.push(format!(r#"key-directory "{}""#, dir));
561        }
562        if let Some(val) = self.sig_validity_interval {
563            parts.push(format!("sig-validity-interval {}", val));
564        }
565        if let Some(val) = self.dnskey_sig_validity {
566            parts.push(format!("dnskey-sig-validity {}", val));
567        }
568
569        // Forwarding options
570        if let Some(mode) = self.forward {
571            parts.push(format!("forward {}", mode.as_str()));
572        }
573        if let Some(ref forwarders) = self.forwarders {
574            if !forwarders.is_empty() {
575                let forwarder_list = forwarders
576                    .iter()
577                    .map(|f| {
578                        if let Some(ref tls) = f.tls_config {
579                            format!("{} tls {}", f.address, tls)
580                        } else if let Some(port) = f.port {
581                            format!("{} port {}", f.address, port)
582                        } else {
583                            f.address.to_string()
584                        }
585                    })
586                    .collect::<Vec<_>>()
587                    .join("; ");
588                parts.push(format!("forwarders {{ {}; }}", forwarder_list));
589            }
590        }
591
592        // Zone maintenance options
593        if let Some(mode) = self.check_names {
594            parts.push(format!("check-names {}", mode.as_str()));
595        }
596        if let Some(mode) = self.check_mx {
597            parts.push(format!("check-mx {}", mode.as_str()));
598        }
599        if let Some(val) = self.check_integrity {
600            parts.push(format!(
601                "check-integrity {}",
602                if val { "yes" } else { "no" }
603            ));
604        }
605        if let Some(format) = self.masterfile_format {
606            parts.push(format!("masterfile-format {}", format.as_str()));
607        }
608        if let Some(val) = self.max_zone_ttl {
609            parts.push(format!("max-zone-ttl {}", val));
610        }
611
612        // Refresh/Retry options
613        if let Some(val) = self.max_refresh_time {
614            parts.push(format!("max-refresh-time {}", val));
615        }
616        if let Some(val) = self.min_refresh_time {
617            parts.push(format!("min-refresh-time {}", val));
618        }
619        if let Some(val) = self.max_retry_time {
620            parts.push(format!("max-retry-time {}", val));
621        }
622        if let Some(val) = self.min_retry_time {
623            parts.push(format!("min-retry-time {}", val));
624        }
625
626        // Miscellaneous options
627        if let Some(val) = self.multi_master {
628            parts.push(format!("multi-master {}", if val { "yes" } else { "no" }));
629        }
630        if let Some(val) = self.request_ixfr {
631            parts.push(format!("request-ixfr {}", if val { "yes" } else { "no" }));
632        }
633        if let Some(val) = self.request_expire {
634            parts.push(format!("request-expire {}", if val { "yes" } else { "no" }));
635        }
636
637        // Raw options (preserve unknown options verbatim)
638        for (key, value) in &self.raw_options {
639            let value_trimmed = value.trim_end().trim_end_matches(';').trim();
640            parts.push(format!("{} {}", key, value_trimmed));
641        }
642
643        format!("{{ {}; }};", parts.join("; "))
644    }
645}