bindcar/
rndc_parser.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! RNDC output parser
5//!
6//! This module provides parsers for BIND9 RNDC command outputs using nom.
7//!
8//! # Examples
9//!
10//! ```rust
11//! use bindcar::rndc_parser::parse_showzone;
12//!
13//! let output = r#"zone "example.com" { type primary; file "/var/cache/bind/example.com.zone"; };"#;
14//! let config = parse_showzone(output).unwrap();
15//! assert_eq!(config.zone_name, "example.com");
16//! ```
17
18use crate::rndc_types::{
19    AutoDnssecMode, CheckNamesMode, DnsClass, ForwardMode, ForwarderSpec, MasterfileFormat,
20    NotifyMode, PrimarySpec, ZoneConfig, ZoneType,
21};
22use nom::{
23    branch::alt,
24    bytes::complete::{tag, take_until, take_while1},
25    character::complete::{char, multispace0},
26    combinator::{map, opt, recognize},
27    multi::many0,
28    sequence::{delimited, preceded, terminated},
29    IResult, Parser,
30};
31use std::net::IpAddr;
32use thiserror::Error;
33
34/// RNDC parse errors
35#[derive(Debug, Error)]
36pub enum RndcParseError {
37    #[error("Parse error: {0}")]
38    ParseError(String),
39
40    #[error("Invalid zone type: {0}")]
41    InvalidZoneType(String),
42
43    #[error("Invalid DNS class: {0}")]
44    InvalidDnsClass(String),
45
46    #[error("Invalid IP address: {0}")]
47    InvalidIpAddress(String),
48
49    #[error("Missing required field: {0}")]
50    MissingField(String),
51
52    #[error("Incomplete input")]
53    Incomplete,
54}
55
56pub type ParseResult<T> = Result<T, RndcParseError>;
57
58// ========== Common Parser Primitives ==========
59
60/// Skip whitespace around a parser
61fn ws<'a, F, O>(inner: F) -> impl Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>
62where
63    F: Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>,
64{
65    delimited(multispace0, inner, multispace0)
66}
67
68/// Parse a semicolon
69fn semicolon(input: &str) -> IResult<&str, char> {
70    ws(char(';')).parse(input)
71}
72
73/// Parse a quoted string: "example"
74pub(crate) fn quoted_string(input: &str) -> IResult<&str, String> {
75    let (input, content) = delimited(char('"'), take_until("\""), char('"')).parse(input)?;
76    Ok((input, content.to_string()))
77}
78
79/// Parse an identifier (alphanumeric with hyphens and underscores)
80fn identifier(input: &str) -> IResult<&str, &str> {
81    take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-')(input)
82}
83
84/// Parse an IP address (IPv4 or IPv6), optionally with CIDR notation
85/// Examples: 192.168.1.1, 192.168.1.1/32, 2001:db8::1, 2001:db8::1/128
86pub(crate) fn ip_addr(input: &str) -> IResult<&str, IpAddr> {
87    // Try to parse as much as possible that looks like an IP address
88    let (input, addr_str) = recognize(take_while1(|c: char| {
89        c.is_ascii_hexdigit() || c == '.' || c == ':'
90    }))
91    .parse(input)?;
92
93    // Try to parse the string as an IP address
94    let addr = match addr_str.parse::<IpAddr>() {
95        Ok(addr) => addr,
96        Err(_) => {
97            return Err(nom::Err::Error(nom::error::Error::new(
98                input,
99                nom::error::ErrorKind::Verify,
100            )))
101        }
102    };
103
104    // Check for optional CIDR suffix (e.g., /32 or /128) and consume it
105    let (input, _) =
106        opt(preceded(char('/'), take_while1(|c: char| c.is_numeric()))).parse(input)?;
107
108    Ok((input, addr))
109}
110
111/// Parse IP address with optional port
112pub(crate) fn ip_with_port(input: &str) -> IResult<&str, PrimarySpec> {
113    let (input, addr) = ws(ip_addr).parse(input)?;
114    let (input, port) = opt(preceded(
115        ws(tag("port")),
116        map(take_while1(|c: char| c.is_numeric()), |s: &str| {
117            s.parse::<u16>().ok()
118        }),
119    ))
120    .parse(input)?;
121
122    Ok((
123        input,
124        PrimarySpec {
125            address: addr,
126            port: port.flatten(),
127        },
128    ))
129}
130
131/// Parse a list of IP addresses: { addr; addr; }
132fn ip_list(input: &str) -> IResult<&str, Vec<IpAddr>> {
133    delimited(
134        ws(char('{')),
135        many0(terminated(ws(ip_addr), semicolon)),
136        ws(char('}')),
137    )
138    .parse(input)
139}
140
141/// Parse a list of primary specs: { addr; addr port 5353; }
142fn primary_list(input: &str) -> IResult<&str, Vec<PrimarySpec>> {
143    delimited(
144        ws(char('{')),
145        many0(terminated(ip_with_port, semicolon)),
146        ws(char('}')),
147    )
148    .parse(input)
149}
150
151// ========== Zone Configuration Parser ==========
152
153/// Statement types within a zone configuration
154#[derive(Debug)]
155#[allow(dead_code)]
156enum ZoneStatement {
157    // Core
158    Type(ZoneType),
159    File(String),
160
161    // Primary/Secondary
162    Primaries(Vec<PrimarySpec>),
163    AlsoNotify(Vec<IpAddr>),
164    Notify(NotifyMode),
165
166    // Access Control
167    AllowQuery(Vec<IpAddr>),
168    AllowTransfer(Vec<IpAddr>),
169    AllowUpdate(Vec<IpAddr>),
170    AllowUpdateRaw(String),
171    AllowUpdateForwarding(Vec<IpAddr>),
172    AllowNotify(Vec<IpAddr>),
173
174    // Transfer Control
175    MaxTransferTimeIn(u32),
176    MaxTransferTimeOut(u32),
177    MaxTransferIdleIn(u32),
178    MaxTransferIdleOut(u32),
179    TransferSource(IpAddr),
180    TransferSourceV6(IpAddr),
181    NotifySource(IpAddr),
182    NotifySourceV6(IpAddr),
183
184    // Dynamic Updates
185    UpdatePolicy(String),
186    Journal(String),
187    IxfrFromDifferences(bool),
188
189    // DNSSEC
190    InlineSigning(bool),
191    AutoDnssec(AutoDnssecMode),
192    KeyDirectory(String),
193    SigValidityInterval(u32),
194    DnskeySigValidity(u32),
195
196    // Forwarding
197    Forward(ForwardMode),
198    Forwarders(Vec<ForwarderSpec>),
199
200    // Zone Maintenance
201    CheckNames(CheckNamesMode),
202    CheckMx(CheckNamesMode),
203    CheckIntegrity(bool),
204    MasterfileFormat(MasterfileFormat),
205    MaxZoneTtl(u32),
206
207    // Refresh/Retry
208    MaxRefreshTime(u32),
209    MinRefreshTime(u32),
210    MaxRetryTime(u32),
211    MinRetryTime(u32),
212
213    // Miscellaneous
214    MultiMaster(bool),
215    RequestIxfr(bool),
216    RequestExpire(bool),
217
218    // Catch-all for unknown options
219    Unknown(String, String), // (option_name, raw_value)
220}
221
222/// Parse zone type statement: type primary;
223fn parse_type_statement(input: &str) -> IResult<&str, ZoneStatement> {
224    let (input, _) = ws(tag("type")).parse(input)?;
225    let (input, type_str) = ws(identifier).parse(input)?;
226    let (input, _) = semicolon(input)?;
227
228    let zone_type = ZoneType::parse(type_str).ok_or_else(|| {
229        nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Verify))
230    })?;
231
232    Ok((input, ZoneStatement::Type(zone_type)))
233}
234
235/// Parse file statement: file "/path/to/file";
236fn parse_file_statement(input: &str) -> IResult<&str, ZoneStatement> {
237    let (input, _) = ws(tag("file")).parse(input)?;
238    let (input, file) = ws(quoted_string).parse(input)?;
239    let (input, _) = semicolon(input)?;
240    Ok((input, ZoneStatement::File(file)))
241}
242
243/// Parse primaries statement: primaries { addr; addr port 5353; };
244/// Also handles legacy "masters" keyword
245fn parse_primaries_statement(input: &str) -> IResult<&str, ZoneStatement> {
246    let (input, _) = ws(alt((tag("primaries"), tag("masters")))).parse(input)?;
247    let (input, primaries) = primary_list(input)?;
248    let (input, _) = semicolon(input)?;
249    Ok((input, ZoneStatement::Primaries(primaries)))
250}
251
252/// Parse also-notify statement: also-notify { addr; addr; };
253fn parse_also_notify_statement(input: &str) -> IResult<&str, ZoneStatement> {
254    let (input, _) = ws(tag("also-notify")).parse(input)?;
255    let (input, addrs) = ip_list(input)?;
256    let (input, _) = semicolon(input)?;
257    Ok((input, ZoneStatement::AlsoNotify(addrs)))
258}
259
260/// Parse allow-transfer statement: allow-transfer { addr; addr; };
261fn parse_allow_transfer_statement(input: &str) -> IResult<&str, ZoneStatement> {
262    let (input, _) = ws(tag("allow-transfer")).parse(input)?;
263    let (input, addrs) = ip_list(input)?;
264    let (input, _) = semicolon(input)?;
265    Ok((input, ZoneStatement::AllowTransfer(addrs)))
266}
267
268/// Parse allow-update statement: allow-update { addr; addr; }; or allow-update { key "name"; };
269/// Captures both IP addresses and raw directive for key-based updates
270fn parse_allow_update_statement(input: &str) -> IResult<&str, ZoneStatement> {
271    let (input, _) = ws(tag("allow-update")).parse(input)?;
272
273    // Capture the start position to extract raw content
274    let start_input = input;
275
276    // Parse the content between braces
277    let (input, _) = ws(char('{')).parse(input)?;
278
279    // Collect IP addresses and check for key references
280    let mut addrs = Vec::new();
281    let mut has_key_ref = false;
282    let mut remaining = input;
283
284    loop {
285        // Skip whitespace
286        let (input, _) = multispace0(remaining)?;
287
288        // Check if we've reached the closing brace
289        if let Ok((input, _)) = char::<_, nom::error::Error<&str>>('}')(input) {
290            remaining = input;
291            break;
292        }
293
294        // Check for "key" keyword
295        if let Ok((input, _)) = ws(tag("key")).parse(input) {
296            has_key_ref = true;
297            // Skip to the next semicolon
298            let (input, _) = take_until(";")(input)?;
299            let (input, _) = char(';')(input)?;
300            remaining = input;
301        } else if let Ok((input, addr)) = ip_addr(input) {
302            // Try to parse an IP address
303            addrs.push(addr);
304            // Consume the semicolon
305            let (input, _) = semicolon(input)?;
306            remaining = input;
307        } else {
308            // Skip to the next semicolon (handles other unknown statements)
309            let (input, _) = take_until(";")(input)?;
310            let (input, _) = char(';')(input)?;
311            remaining = input;
312        }
313    }
314
315    let (input, _) = semicolon(remaining)?;
316
317    // If we found key references, capture the raw directive
318    if has_key_ref {
319        // Extract raw content from start to end
320        let raw_len = start_input.len() - input.len();
321        let raw_content = &start_input[..raw_len];
322        Ok((
323            input,
324            ZoneStatement::AllowUpdateRaw(raw_content.to_string()),
325        ))
326    } else {
327        Ok((input, ZoneStatement::AllowUpdate(addrs)))
328    }
329}
330
331/// Parse an unknown/generic zone statement (catch-all)
332/// Format: option-name value; or option-name { ... };
333fn parse_unknown_statement(input: &str) -> IResult<&str, ZoneStatement> {
334    // Parse the option name
335    let (input, option_name) = ws(identifier).parse(input)?;
336
337    // Capture starting position for value
338    let start_input = input;
339
340    // Try to parse value - could be a simple value or a block
341    let (input, _value) = alt((
342        // Block value: { ... };
343        delimited(ws(char('{')), take_until("}"), ws(char('}'))),
344        // Simple value (anything until semicolon)
345        take_until(";"),
346    ))
347    .parse(input)?;
348
349    // Calculate raw value
350    let value_len = start_input.len() - input.len();
351    let raw_value = start_input[..value_len].trim().to_string();
352
353    let (input, _) = semicolon(input)?;
354
355    Ok((
356        input,
357        ZoneStatement::Unknown(option_name.to_string(), raw_value),
358    ))
359}
360
361/// Parse any zone statement
362fn parse_zone_statement(input: &str) -> IResult<&str, ZoneStatement> {
363    alt((
364        parse_type_statement,
365        parse_file_statement,
366        parse_primaries_statement,
367        parse_also_notify_statement,
368        parse_allow_transfer_statement,
369        parse_allow_update_statement,
370        // Catch-all for unknown options (must be last)
371        parse_unknown_statement,
372    ))
373    .parse(input)
374}
375
376/// Parse complete zone configuration from showzone output
377///
378/// Parses output from `rndc showzone <zonename>` which has the format:
379/// ```text
380/// zone "example.com" { type primary; file "/path"; };
381/// ```
382///
383/// Or with optional class:
384/// ```text
385/// zone "example.com" IN { type primary; file "/path"; };
386/// ```
387fn parse_zone_config_internal(input: &str) -> IResult<&str, ZoneConfig> {
388    // Parse: zone "name" [class] { statements };
389    let (input, _) = ws(tag("zone")).parse(input)?;
390    let (input, zone_name) = ws(quoted_string).parse(input)?;
391
392    // Optional class (IN, CH, HS)
393    let (input, class) = opt(ws(alt((tag("IN"), tag("CH"), tag("HS"))))).parse(input)?;
394    let class = match class {
395        Some("IN") => DnsClass::IN,
396        Some("CH") => DnsClass::CH,
397        Some("HS") => DnsClass::HS,
398        _ => DnsClass::IN, // Default to IN
399    };
400
401    // Parse zone block
402    let (input, statements) =
403        delimited(ws(char('{')), many0(parse_zone_statement), ws(tag("};"))).parse(input)?;
404
405    // Build ZoneConfig from statements
406    let mut config = ZoneConfig::new(zone_name, ZoneType::Primary); // Default type
407    config.class = class;
408
409    for stmt in statements {
410        match stmt {
411            // Core
412            ZoneStatement::Type(t) => config.zone_type = t,
413            ZoneStatement::File(f) => config.file = Some(f),
414
415            // Primary/Secondary
416            ZoneStatement::Primaries(p) => config.primaries = Some(p),
417            ZoneStatement::AlsoNotify(a) => config.also_notify = Some(a),
418            ZoneStatement::Notify(n) => config.notify = Some(n),
419
420            // Access Control
421            ZoneStatement::AllowQuery(a) => config.allow_query = Some(a),
422            ZoneStatement::AllowTransfer(a) => config.allow_transfer = Some(a),
423            ZoneStatement::AllowUpdate(a) => config.allow_update = Some(a),
424            ZoneStatement::AllowUpdateRaw(raw) => config.allow_update_raw = Some(raw),
425            ZoneStatement::AllowUpdateForwarding(a) => config.allow_update_forwarding = Some(a),
426            ZoneStatement::AllowNotify(a) => config.allow_notify = Some(a),
427
428            // Transfer Control
429            ZoneStatement::MaxTransferTimeIn(v) => config.max_transfer_time_in = Some(v),
430            ZoneStatement::MaxTransferTimeOut(v) => config.max_transfer_time_out = Some(v),
431            ZoneStatement::MaxTransferIdleIn(v) => config.max_transfer_idle_in = Some(v),
432            ZoneStatement::MaxTransferIdleOut(v) => config.max_transfer_idle_out = Some(v),
433            ZoneStatement::TransferSource(ip) => config.transfer_source = Some(ip),
434            ZoneStatement::TransferSourceV6(ip) => config.transfer_source_v6 = Some(ip),
435            ZoneStatement::NotifySource(ip) => config.notify_source = Some(ip),
436            ZoneStatement::NotifySourceV6(ip) => config.notify_source_v6 = Some(ip),
437
438            // Dynamic Updates
439            ZoneStatement::UpdatePolicy(p) => config.update_policy = Some(p),
440            ZoneStatement::Journal(j) => config.journal = Some(j),
441            ZoneStatement::IxfrFromDifferences(v) => config.ixfr_from_differences = Some(v),
442
443            // DNSSEC
444            ZoneStatement::InlineSigning(v) => config.inline_signing = Some(v),
445            ZoneStatement::AutoDnssec(m) => config.auto_dnssec = Some(m),
446            ZoneStatement::KeyDirectory(d) => config.key_directory = Some(d),
447            ZoneStatement::SigValidityInterval(v) => config.sig_validity_interval = Some(v),
448            ZoneStatement::DnskeySigValidity(v) => config.dnskey_sig_validity = Some(v),
449
450            // Forwarding
451            ZoneStatement::Forward(m) => config.forward = Some(m),
452            ZoneStatement::Forwarders(f) => config.forwarders = Some(f),
453
454            // Zone Maintenance
455            ZoneStatement::CheckNames(m) => config.check_names = Some(m),
456            ZoneStatement::CheckMx(m) => config.check_mx = Some(m),
457            ZoneStatement::CheckIntegrity(v) => config.check_integrity = Some(v),
458            ZoneStatement::MasterfileFormat(f) => config.masterfile_format = Some(f),
459            ZoneStatement::MaxZoneTtl(v) => config.max_zone_ttl = Some(v),
460
461            // Refresh/Retry
462            ZoneStatement::MaxRefreshTime(v) => config.max_refresh_time = Some(v),
463            ZoneStatement::MinRefreshTime(v) => config.min_refresh_time = Some(v),
464            ZoneStatement::MaxRetryTime(v) => config.max_retry_time = Some(v),
465            ZoneStatement::MinRetryTime(v) => config.min_retry_time = Some(v),
466
467            // Miscellaneous
468            ZoneStatement::MultiMaster(v) => config.multi_master = Some(v),
469            ZoneStatement::RequestIxfr(v) => config.request_ixfr = Some(v),
470            ZoneStatement::RequestExpire(v) => config.request_expire = Some(v),
471
472            // Catch-all
473            ZoneStatement::Unknown(key, value) => {
474                config.raw_options.insert(key, value);
475            }
476        }
477    }
478
479    Ok((input, config))
480}
481
482/// Parse `rndc showzone` output
483///
484/// # Examples
485///
486/// ```rust
487/// use bindcar::rndc_parser::parse_showzone;
488///
489/// let output = r#"zone "example.com" { type primary; file "/var/cache/bind/example.com.zone"; };"#;
490/// let config = parse_showzone(output).unwrap();
491/// assert_eq!(config.zone_name, "example.com");
492/// ```
493pub fn parse_showzone(input: &str) -> ParseResult<ZoneConfig> {
494    match parse_zone_config_internal(input.trim()) {
495        Ok((_, config)) => Ok(config),
496        Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => {
497            Err(RndcParseError::ParseError(format!("Parse failed: {:?}", e)))
498        }
499        Err(nom::Err::Incomplete(_)) => Err(RndcParseError::Incomplete),
500    }
501}