bindcar/
rndc_conf_parser.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! RNDC configuration file parser
5//!
6//! This module provides parsers for BIND9 rndc.conf files using nom.
7//!
8//! # Examples
9//!
10//! ```rust
11//! use bindcar::rndc_conf_parser::parse_rndc_conf_str;
12//!
13//! let conf_str = r#"
14//! key "rndc-key" {
15//!     algorithm hmac-sha256;
16//!     secret "dGVzdC1zZWNyZXQ=";
17//! };
18//!
19//! options {
20//!     default-server localhost;
21//!     default-key "rndc-key";
22//! };
23//! "#;
24//!
25//! let config = parse_rndc_conf_str(conf_str).unwrap();
26//! assert_eq!(config.keys.len(), 1);
27//! ```
28
29use crate::rndc_conf_types::{KeyBlock, OptionsBlock, RndcConfFile, ServerAddress, ServerBlock};
30use nom::{
31    branch::alt,
32    bytes::complete::{tag, take_until, take_while, take_while1},
33    character::complete::{char, digit1, multispace0, multispace1},
34    combinator::{map, map_res, recognize, value},
35    multi::{many0, separated_list0},
36    sequence::{delimited, preceded},
37    IResult, Parser,
38};
39use std::collections::HashSet;
40use std::net::IpAddr;
41use std::path::{Path, PathBuf};
42use thiserror::Error;
43
44/// RNDC configuration parse errors
45#[derive(Debug, Error)]
46pub enum RndcConfParseError {
47    #[error("Parse error: {0}")]
48    ParseError(String),
49
50    #[error("Invalid server address: {0}")]
51    InvalidServerAddress(String),
52
53    #[error("Invalid IP address: {0}")]
54    InvalidIpAddress(String),
55
56    #[error("Missing required field: {0}")]
57    MissingField(String),
58
59    #[error("Circular include detected: {0}")]
60    CircularInclude(String),
61
62    #[error("File not found: {0}")]
63    FileNotFound(String),
64
65    #[error("IO error: {0}")]
66    IoError(#[from] std::io::Error),
67
68    #[error("Incomplete input")]
69    Incomplete,
70}
71
72pub type ParseResult<T> = Result<T, RndcConfParseError>;
73
74// ========== Comment and Whitespace Parsers ==========
75
76/// Parse C-style line comment: // comment
77fn line_comment(input: &str) -> IResult<&str, ()> {
78    let (input, _) = tag("//")(input)?;
79    let (input, _) = take_while(|c| c != '\n')(input)?;
80    Ok((input, ()))
81}
82
83/// Parse hash comment: # comment
84fn hash_comment(input: &str) -> IResult<&str, ()> {
85    let (input, _) = char('#')(input)?;
86    let (input, _) = take_while(|c| c != '\n')(input)?;
87    Ok((input, ()))
88}
89
90/// Parse C-style block comment: /* comment */
91fn block_comment(input: &str) -> IResult<&str, ()> {
92    value((), (tag("/*"), take_until("*/"), tag("*/"))).parse(input)
93}
94
95/// Parse any type of comment
96fn comment(input: &str) -> IResult<&str, ()> {
97    alt((line_comment, hash_comment, block_comment)).parse(input)
98}
99
100/// Skip whitespace and comments
101fn ws<'a, F, O>(inner: F) -> impl Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>
102where
103    F: Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>,
104{
105    delimited(
106        many0(alt((value((), multispace1), comment))),
107        inner,
108        many0(alt((value((), multispace1), comment))),
109    )
110}
111
112/// Parse a semicolon with surrounding whitespace
113fn semicolon(input: &str) -> IResult<&str, char> {
114    ws(char(';')).parse(input)
115}
116
117// ========== String and Identifier Parsers ==========
118
119/// Parse escaped character in quoted string
120fn escaped_char(input: &str) -> IResult<&str, char> {
121    preceded(
122        char('\\'),
123        alt((
124            value('"', char('"')),
125            value('\\', char('\\')),
126            value('\n', char('n')),
127            value('\r', char('r')),
128            value('\t', char('t')),
129        )),
130    )
131    .parse(input)
132}
133
134/// Parse quoted string with escape sequences: "example"
135fn quoted_string(input: &str) -> IResult<&str, String> {
136    delimited(
137        char('"'),
138        map(
139            many0(alt((
140                map(escaped_char, |c| c.to_string()),
141                map(take_while1(|c| c != '"' && c != '\\'), |s: &str| {
142                    s.to_string()
143                }),
144            ))),
145            |parts| parts.join(""),
146        ),
147        char('"'),
148    )
149    .parse(input)
150}
151
152/// Parse an identifier (alphanumeric with hyphens, underscores, dots, and colons)
153/// Examples: rndc-key, hmac-sha256, 127.0.0.1, localhost, 2001:db8::1
154fn identifier(input: &str) -> IResult<&str, &str> {
155    take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':')(
156        input,
157    )
158}
159
160// ========== IP Address and Port Parsers ==========
161
162/// Parse an IPv4 address
163fn ipv4_addr(input: &str) -> IResult<&str, IpAddr> {
164    let (input, addr_str) = recognize((
165        digit1,
166        char('.'),
167        digit1,
168        char('.'),
169        digit1,
170        char('.'),
171        digit1,
172    ))
173    .parse(input)?;
174
175    let addr = match addr_str.parse::<IpAddr>() {
176        Ok(addr) => addr,
177        Err(_) => {
178            return Err(nom::Err::Error(nom::error::Error::new(
179                input,
180                nom::error::ErrorKind::Verify,
181            )))
182        }
183    };
184
185    Ok((input, addr))
186}
187
188/// Parse an IPv6 address
189fn ipv6_addr(input: &str) -> IResult<&str, IpAddr> {
190    let (input, addr_str) =
191        recognize(take_while1(|c: char| c.is_ascii_hexdigit() || c == ':')).parse(input)?;
192
193    // Must contain at least two colons to be valid IPv6
194    if !addr_str.contains("::") && addr_str.matches(':').count() < 2 {
195        return Err(nom::Err::Error(nom::error::Error::new(
196            input,
197            nom::error::ErrorKind::Verify,
198        )));
199    }
200
201    let addr = match addr_str.parse::<IpAddr>() {
202        Ok(addr) => addr,
203        Err(_) => {
204            return Err(nom::Err::Error(nom::error::Error::new(
205                input,
206                nom::error::ErrorKind::Verify,
207            )))
208        }
209    };
210
211    Ok((input, addr))
212}
213
214/// Parse an IP address (IPv4 or IPv6)
215fn ip_addr(input: &str) -> IResult<&str, IpAddr> {
216    alt((ipv6_addr, ipv4_addr)).parse(input)
217}
218
219/// Parse a port number.
220///
221/// A value that overflows `u16` (e.g. `default-port 99999`) is a parse error
222/// rather than being silently masked to a default (A16): a config typo fails
223/// loudly instead of quietly redirecting the client to an unintended port.
224fn port_number(input: &str) -> IResult<&str, u16> {
225    map_res(digit1, |s: &str| s.parse::<u16>()).parse(input)
226}
227
228/// Parse a server address (hostname or IP)
229fn server_address(input: &str) -> IResult<&str, ServerAddress> {
230    // Try IP address first, then fall back to hostname
231    alt((
232        map(ip_addr, ServerAddress::IpAddr),
233        map(identifier, |s: &str| ServerAddress::Hostname(s.to_string())),
234    ))
235    .parse(input)
236}
237
238// ========== Key Block Parser ==========
239
240/// Key block field types
241enum KeyField {
242    Algorithm(String),
243    Secret(String),
244}
245
246// Manual `Debug` that redacts the secret variant (A4) so an intermediate parse
247// value can never carry the plaintext TSIG secret into a debug print.
248impl std::fmt::Debug for KeyField {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            KeyField::Algorithm(a) => f.debug_tuple("Algorithm").field(a).finish(),
252            KeyField::Secret(_) => f.debug_tuple("Secret").field(&"[REDACTED]").finish(),
253        }
254    }
255}
256
257/// Parse algorithm field: algorithm hmac-sha256;
258fn parse_algorithm_field(input: &str) -> IResult<&str, KeyField> {
259    let (input, _) = ws(tag("algorithm")).parse(input)?;
260    let (input, algo) = ws(identifier).parse(input)?;
261    let (input, _) = semicolon(input)?;
262    Ok((input, KeyField::Algorithm(algo.to_string())))
263}
264
265/// Parse secret field: secret "base64string";
266fn parse_secret_field(input: &str) -> IResult<&str, KeyField> {
267    let (input, _) = ws(tag("secret")).parse(input)?;
268    let (input, secret) = ws(quoted_string).parse(input)?;
269    let (input, _) = semicolon(input)?;
270    Ok((input, KeyField::Secret(secret)))
271}
272
273/// Parse key field
274fn parse_key_field(input: &str) -> IResult<&str, KeyField> {
275    alt((parse_algorithm_field, parse_secret_field)).parse(input)
276}
277
278/// Parse key block: key "name" { algorithm ...; secret "..."; };
279fn parse_key_block(input: &str) -> IResult<&str, (String, KeyBlock)> {
280    let (input, _) = ws(tag("key")).parse(input)?;
281    let (input, name) = ws(quoted_string).parse(input)?;
282    let (input, fields) =
283        delimited(ws(char('{')), many0(parse_key_field), ws(tag("};"))).parse(input)?;
284
285    let mut algorithm = None;
286    let mut secret = None;
287
288    for field in fields {
289        match field {
290            KeyField::Algorithm(a) => algorithm = Some(a),
291            KeyField::Secret(s) => secret = Some(s),
292        }
293    }
294
295    let key_block = KeyBlock {
296        name: name.clone(),
297        algorithm: algorithm.unwrap_or_else(|| "hmac-sha256".to_string()),
298        secret: secret.unwrap_or_default(),
299    };
300
301    Ok((input, (name, key_block)))
302}
303
304// ========== Server Block Parser ==========
305
306/// Server block field types
307#[derive(Debug)]
308enum ServerField {
309    Key(String),
310    Port(u16),
311    Addresses(Vec<IpAddr>),
312}
313
314/// Parse key field: key "keyname";
315fn parse_server_key_field(input: &str) -> IResult<&str, ServerField> {
316    let (input, _) = ws(tag("key")).parse(input)?;
317    let (input, key) = ws(quoted_string).parse(input)?;
318    let (input, _) = semicolon(input)?;
319    Ok((input, ServerField::Key(key)))
320}
321
322/// Parse port field: port 953;
323fn parse_server_port_field(input: &str) -> IResult<&str, ServerField> {
324    let (input, _) = ws(tag("port")).parse(input)?;
325    let (input, port) = ws(port_number).parse(input)?;
326    let (input, _) = semicolon(input)?;
327    Ok((input, ServerField::Port(port)))
328}
329
330/// Parse addresses field: addresses { ip; ip; };
331fn parse_server_addresses_field(input: &str) -> IResult<&str, ServerField> {
332    let (input, _) = ws(tag("addresses")).parse(input)?;
333    let (input, addrs) = delimited(
334        ws(char('{')),
335        separated_list0(semicolon, ws(ip_addr)),
336        ws(tag("};")),
337    )
338    .parse(input)?;
339    Ok((input, ServerField::Addresses(addrs)))
340}
341
342/// Parse server field
343fn parse_server_field(input: &str) -> IResult<&str, ServerField> {
344    alt((
345        parse_server_key_field,
346        parse_server_port_field,
347        parse_server_addresses_field,
348    ))
349    .parse(input)
350}
351
352/// Parse server block: server address { key "..."; port 953; };
353fn parse_server_block(input: &str) -> IResult<&str, (String, ServerBlock)> {
354    let (input, _) = ws(tag("server")).parse(input)?;
355    let (input, addr) = ws(server_address).parse(input)?;
356    let (input, fields) =
357        delimited(ws(char('{')), many0(parse_server_field), ws(tag("};"))).parse(input)?;
358
359    let mut server = ServerBlock::new(addr.clone());
360
361    for field in fields {
362        match field {
363            ServerField::Key(k) => server.key = Some(k),
364            ServerField::Port(p) => server.port = Some(p),
365            ServerField::Addresses(a) => server.addresses = Some(a),
366        }
367    }
368
369    Ok((input, (addr.to_string(), server)))
370}
371
372// ========== Options Block Parser ==========
373
374/// Options block field types
375#[derive(Debug)]
376#[allow(clippy::enum_variant_names)]
377enum OptionField {
378    DefaultServer(String),
379    DefaultKey(String),
380    DefaultPort(u16),
381}
382
383/// Parse default-server field: default-server localhost;
384fn parse_default_server_field(input: &str) -> IResult<&str, OptionField> {
385    let (input, _) = ws(tag("default-server")).parse(input)?;
386    let (input, server) = ws(identifier).parse(input)?;
387    let (input, _) = semicolon(input)?;
388    Ok((input, OptionField::DefaultServer(server.to_string())))
389}
390
391/// Parse default-key field: default-key "keyname";
392fn parse_default_key_field(input: &str) -> IResult<&str, OptionField> {
393    let (input, _) = ws(tag("default-key")).parse(input)?;
394    let (input, key) = ws(quoted_string).parse(input)?;
395    let (input, _) = semicolon(input)?;
396    Ok((input, OptionField::DefaultKey(key)))
397}
398
399/// Parse default-port field: default-port 953;
400fn parse_default_port_field(input: &str) -> IResult<&str, OptionField> {
401    let (input, _) = ws(tag("default-port")).parse(input)?;
402    let (input, port) = ws(port_number).parse(input)?;
403    let (input, _) = semicolon(input)?;
404    Ok((input, OptionField::DefaultPort(port)))
405}
406
407/// Parse options field
408fn parse_option_field(input: &str) -> IResult<&str, OptionField> {
409    alt((
410        parse_default_server_field,
411        parse_default_key_field,
412        parse_default_port_field,
413    ))
414    .parse(input)
415}
416
417/// Parse options block: options { default-server localhost; };
418fn parse_options_block(input: &str) -> IResult<&str, OptionsBlock> {
419    let (input, _) = ws(tag("options")).parse(input)?;
420    let (input, fields) =
421        delimited(ws(char('{')), many0(parse_option_field), ws(tag("};"))).parse(input)?;
422
423    let mut options = OptionsBlock::new();
424
425    for field in fields {
426        match field {
427            OptionField::DefaultServer(s) => options.default_server = Some(s),
428            OptionField::DefaultKey(k) => options.default_key = Some(k),
429            OptionField::DefaultPort(p) => options.default_port = Some(p),
430        }
431    }
432
433    Ok((input, options))
434}
435
436// ========== Include Statement Parser ==========
437
438/// Parse include statement: include "/path/to/file";
439fn parse_include_stmt(input: &str) -> IResult<&str, PathBuf> {
440    let (input, _) = ws(tag("include")).parse(input)?;
441    let (input, path) = ws(quoted_string).parse(input)?;
442    let (input, _) = semicolon(input)?;
443    Ok((input, PathBuf::from(path)))
444}
445
446// ========== Statement Parser ==========
447
448/// Statement types in rndc.conf
449#[derive(Debug)]
450enum Statement {
451    Include(PathBuf),
452    Key(String, KeyBlock),
453    Server(String, ServerBlock),
454    Options(OptionsBlock),
455}
456
457/// Parse any statement
458fn parse_statement(input: &str) -> IResult<&str, Statement> {
459    alt((
460        map(parse_include_stmt, Statement::Include),
461        map(parse_key_block, |(name, key)| Statement::Key(name, key)),
462        map(parse_server_block, |(addr, srv)| {
463            Statement::Server(addr, srv)
464        }),
465        map(parse_options_block, Statement::Options),
466    ))
467    .parse(input)
468}
469
470// ========== File Parser ==========
471
472/// Parse rndc.conf file content (internal)
473fn parse_rndc_conf_internal(input: &str) -> IResult<&str, RndcConfFile> {
474    let (input, statements) = many0(ws(parse_statement)).parse(input)?;
475    let (input, _) = multispace0(input)?;
476
477    let mut conf = RndcConfFile::new();
478
479    for stmt in statements {
480        match stmt {
481            Statement::Include(path) => conf.includes.push(path),
482            Statement::Key(name, key) => {
483                conf.keys.insert(name, key);
484            }
485            Statement::Server(addr, server) => {
486                conf.servers.insert(addr, server);
487            }
488            Statement::Options(opts) => {
489                // Merge options (last one wins)
490                if opts.default_server.is_some() {
491                    conf.options.default_server = opts.default_server;
492                }
493                if opts.default_key.is_some() {
494                    conf.options.default_key = opts.default_key;
495                }
496                if opts.default_port.is_some() {
497                    conf.options.default_port = opts.default_port;
498                }
499            }
500        }
501    }
502
503    Ok((input, conf))
504}
505
506/// Parse rndc.conf from string
507///
508/// # Examples
509///
510/// ```rust
511/// use bindcar::rndc_conf_parser::parse_rndc_conf_str;
512///
513/// let conf_str = r#"
514/// key "rndc-key" {
515///     algorithm hmac-sha256;
516///     secret "dGVzdC1zZWNyZXQ=";
517/// };
518/// "#;
519///
520/// let config = parse_rndc_conf_str(conf_str).unwrap();
521/// assert_eq!(config.keys.len(), 1);
522/// ```
523pub fn parse_rndc_conf_str(input: &str) -> ParseResult<RndcConfFile> {
524    match parse_rndc_conf_internal(input) {
525        Ok((remaining, conf)) => {
526            // Fail loudly on trailing unparsed input instead of silently
527            // discarding it. `many0` stops at the first line it cannot parse
528            // (e.g. a missing ';' after `secret "..."`), which previously caused
529            // the whole key to be dropped and yielded a client with an EMPTY
530            // secret. Report only the byte offset — never the remainder itself,
531            // which for an rndc.conf can contain the TSIG secret (A3).
532            if !remaining.trim().is_empty() {
533                let offset = input.len().saturating_sub(remaining.len());
534                return Err(RndcConfParseError::ParseError(format!(
535                    "malformed rndc.conf: unparsed input near byte {}",
536                    offset
537                )));
538            }
539            Ok(conf)
540        }
541        Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => {
542            // Never echo the unparsed remainder: for an rndc.conf it can contain
543            // the TSIG `secret "..."` value, which would then leak into logs or
544            // API error output (A3). Report only the byte offset and the nom
545            // error kind — enough to locate the fault, nothing sensitive.
546            let offset = input.len().saturating_sub(e.input.len());
547            Err(RndcConfParseError::ParseError(format!(
548                "malformed rndc.conf near byte {} ({:?})",
549                offset, e.code
550            )))
551        }
552        Err(nom::Err::Incomplete(_)) => Err(RndcConfParseError::Incomplete),
553    }
554}
555
556/// Parse rndc.conf from file with include resolution
557///
558/// Handles include directives and detects circular includes.
559///
560/// # Examples
561///
562/// ```rust,no_run
563/// use bindcar::rndc_conf_parser::parse_rndc_conf_file;
564/// use std::path::Path;
565///
566/// let config = parse_rndc_conf_file(Path::new("/etc/bind/rndc.conf")).unwrap();
567/// ```
568pub fn parse_rndc_conf_file(path: &Path) -> ParseResult<RndcConfFile> {
569    let mut visited = HashSet::new();
570    parse_rndc_conf_file_recursive(path, &mut visited, 0)
571}
572
573/// Maximum depth of nested `include` directives.
574///
575/// Cycles are already blocked by the `visited` set, but a long *acyclic* include
576/// chain would recurse one stack frame per file and could exhaust the stack
577/// (A17). This bounds the nesting to a generous but finite depth.
578const MAX_INCLUDE_DEPTH: usize = 32;
579
580/// Recursively parse rndc.conf file with include resolution
581fn parse_rndc_conf_file_recursive(
582    path: &Path,
583    visited: &mut HashSet<PathBuf>,
584    depth: usize,
585) -> ParseResult<RndcConfFile> {
586    if depth > MAX_INCLUDE_DEPTH {
587        return Err(RndcConfParseError::ParseError(format!(
588            "include nesting exceeds maximum depth of {}",
589            MAX_INCLUDE_DEPTH
590        )));
591    }
592
593    // Check for circular includes
594    let canonical_path = path
595        .canonicalize()
596        .map_err(|_| RndcConfParseError::FileNotFound(path.display().to_string()))?;
597
598    if visited.contains(&canonical_path) {
599        return Err(RndcConfParseError::CircularInclude(
600            canonical_path.display().to_string(),
601        ));
602    }
603
604    visited.insert(canonical_path.clone());
605
606    // Read and parse main file
607    let content = std::fs::read_to_string(path)?;
608    let mut conf = parse_rndc_conf_str(&content)?;
609
610    // Resolve includes
611    let includes = conf.includes.clone();
612    conf.includes.clear();
613
614    for include_path in includes {
615        // Resolve relative paths
616        let resolved_path = if include_path.is_absolute() {
617            include_path
618        } else {
619            path.parent()
620                .unwrap_or_else(|| Path::new("."))
621                .join(include_path)
622        };
623
624        // Parse included file
625        let included_conf = parse_rndc_conf_file_recursive(&resolved_path, visited, depth + 1)?;
626
627        // Merge configurations
628        for (name, key) in included_conf.keys {
629            conf.keys.entry(name).or_insert(key);
630        }
631
632        for (addr, server) in included_conf.servers {
633            conf.servers.entry(addr).or_insert(server);
634        }
635
636        // Merge options (main file takes precedence)
637        if conf.options.default_server.is_none() {
638            conf.options.default_server = included_conf.options.default_server;
639        }
640        if conf.options.default_key.is_none() {
641            conf.options.default_key = included_conf.options.default_key;
642        }
643        if conf.options.default_port.is_none() {
644            conf.options.default_port = included_conf.options.default_port;
645        }
646
647        conf.includes.push(resolved_path);
648    }
649
650    Ok(conf)
651}
652
653#[cfg(test)]
654#[path = "rndc_conf_parser_tests.rs"]
655mod tests;