From ea56afd2f292aac08b35ecbbad5c8cc8c96c64d9 Mon Sep 17 00:00:00 2001 From: Keenan Tims Date: Thu, 22 Oct 2026 17:48:59 -0700 Subject: [PATCH] Add YAML configuration model and loader Config is layered: built-in defaults, then the YAML file, then BFD_* environment variables, then CLI flags. Global defaults live under a 'defaults' mapping; per-peer settings are file-only. Includes config.yaml.dist and ScriptHookSink::with_script_dir so the hooks directory is configurable. --- Cargo.lock | 83 +++++++ Cargo.toml | 6 +- config.yaml.dist | 41 ++++ src/config.rs | 620 +++++++++++++++++++++++++++++++++++++++++++++++ src/events.rs | 5 +- src/lib.rs | 1 + 6 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 config.yaml.dist create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 0ef244b..b53b733 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "errno" version = "0.3.14" @@ -370,12 +376,44 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -391,6 +429,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jiff" version = "0.2.38" @@ -708,6 +752,8 @@ dependencies = [ "clap", "env_logger", "futures 0.3.34", + "humantime", + "humantime-serde", "itertools", "log", "nix", @@ -715,6 +761,8 @@ dependencies = [ "nom-derive", "proc-bitfield", "rand", + "serde", + "serde_yaml_ng", "tokio", "tokio-timer", ] @@ -725,12 +773,28 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -751,6 +815,19 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -904,6 +981,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index dc9cd0c..23ba023 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,11 @@ edition = "2021" atomic = "0.6.0" bytemuck = { version = "1.14.1", features = ["derive"] } byteorder = "1.5.0" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } env_logger = "0.11.1" futures = "0.3.30" +humantime = "2.4.0" +humantime-serde = "1.1.1" itertools = "0.12.1" log = "0.4.20" nix = { version = "0.31.3", features = ["fs", "socket"] } @@ -17,5 +19,7 @@ nom = "7.1.3" nom-derive = "0.10.1" proc-bitfield = "0.3.1" rand = "0.8.5" +serde = { version = "1.0.229", features = ["derive"] } +serde_yaml_ng = "0.10.0" tokio = { version = "1.35.1", features = ["net", "full"] } tokio-timer = "0.2.13" diff --git a/config.yaml.dist b/config.yaml.dist new file mode 100644 index 0000000..0654b0a --- /dev/null +++ b/config.yaml.dist @@ -0,0 +1,41 @@ +# rust-bfd configuration. Install as /etc/rust-bfd/config.yaml (or pass --config / set BFD_CONFIG). +# +# Precedence, lowest to highest: built-in defaults < this file < BFD_* environment < command line. +# Values shown below are the built-in defaults unless noted. Unknown keys are an error. + +# Local address(es) to bind and use as the source of BFD control packets. Required (here, in BFD_BIND, or via --bind). +# Accepts a single address, a list, or a comma-separated string. The daemon currently supports exactly one address; +# the list form is accepted so configs do not need to change once multiple binds are implemented. +# Env: BFD_BIND="192.0.2.1" (or "192.0.2.1,2001:db8::1") +# CLI: --bind 192.0.2.1 [--bind ...] (replaces the addresses configured here) +bind: 192.0.2.1 +# bind: [192.0.2.1, "2001:db8::1"] + +# Directory of executable scripts run, in lexical order, on every session state change. +# Env: BFD_HOOKS_DIR +hooks_dir: /etc/rust-bfd/hooks.d + +# Settings inherited by every peer that does not override them. Durations are humantime strings (300ms, 1s, 2m). +# A peer is never allowed a zero interval or a detect_mult of 0. +defaults: + # Minimum interval between transmitted control packets that we would like to use once the session is Up. Sessions + # that are not Up transmit no faster than once per second regardless (RFC 5880 section 6.8.3). + # Env: BFD_DEFAULTS_DESIRED_MIN_TX + desired_min_tx: 300ms + # Minimum interval between received control packets we can support. + # Env: BFD_DEFAULTS_REQUIRED_MIN_RX + required_min_rx: 300ms + # Detection time multiplier: we declare the session down after this many missed packets. + # Env: BFD_DEFAULTS_DETECT_MULT + detect_mult: 3 + +# Peers to establish sessions with. Peers can only be configured here and with --peer (repeatable, addresses only, +# default settings); they cannot be set from the environment. Each peer's address must match the family of at least +# one bind address. +peers: + - address: 192.0.2.2 + # Per-peer overrides of any `defaults` key: + # - address: 192.0.2.3 + # desired_min_tx: 1s + # required_min_rx: 1s + # detect_mult: 5 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7ed7032 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,620 @@ +//! Daemon configuration: YAML model, layered loading and validation. +//! +//! Layers, lowest to highest precedence: built-in defaults, the YAML file, `BFD_*` environment variables (global +//! settings only), command line (`--bind` replaces, `--peer` adds). Per-peer settings can only come from the file; peers +//! given on the command line use the global defaults. +//! +//! [`Config`] is the editable, serialisable model. [`Config::resolve`] validates it and produces a [`ResolvedConfig`] +//! with all per-peer inheritance applied, which is what the daemon runs from. + +use std::{ + collections::HashSet, + error::Error, + fmt, fs, io, + net::IpAddr, + path::{Path, PathBuf}, + time::Duration, +}; + +use serde::{de, Deserialize, Deserializer, Serialize}; + +use crate::bfd::BfdInterval; + +pub const DEFAULT_CONFIG_PATH: &str = "/etc/rust-bfd/config.yaml"; +pub const DEFAULT_HOOKS_DIR: &str = "/etc/rust-bfd/hooks.d"; + +pub const ENV_BIND: &str = "BFD_BIND"; +pub const ENV_HOOKS_DIR: &str = "BFD_HOOKS_DIR"; +pub const ENV_DEFAULTS_DESIRED_MIN_TX: &str = "BFD_DEFAULTS_DESIRED_MIN_TX"; +pub const ENV_DEFAULTS_REQUIRED_MIN_RX: &str = "BFD_DEFAULTS_REQUIRED_MIN_RX"; +pub const ENV_DEFAULTS_DETECT_MULT: &str = "BFD_DEFAULTS_DETECT_MULT"; + +#[derive(Debug)] +pub enum ConfigError { + Io { + path: PathBuf, + source: io::Error, + }, + Parse { + path: PathBuf, + source: serde_yaml_ng::Error, + }, + Env { + var: &'static str, + message: String, + }, + Invalid(String), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => { + write!(f, "cannot read config file `{}`: {source}", path.display()) + } + Self::Parse { path, source } => { + write!(f, "invalid config file `{}`: {source}", path.display()) + } + Self::Env { var, message } => { + write!(f, "invalid environment variable {var}: {message}") + } + Self::Invalid(message) => write!(f, "invalid configuration: {message}"), + } + } +} + +impl Error for ConfigError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Parse { source, .. } => Some(source), + Self::Env { .. } | Self::Invalid(_) => None, + } + } +} + +/// Timer and multiplier settings inherited by every peer that does not override them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Defaults { + #[serde(with = "humantime_serde")] + pub desired_min_tx: Duration, + #[serde(with = "humantime_serde")] + pub required_min_rx: Duration, + pub detect_mult: u8, +} + +impl Default for Defaults { + fn default() -> Self { + Self { + desired_min_tx: Duration::from_millis(300), + required_min_rx: Duration::from_millis(300), + detect_mult: 3, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PeerConfig { + pub address: IpAddr, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "humantime_serde" + )] + pub desired_min_tx: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "humantime_serde" + )] + pub required_min_rx: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detect_mult: Option, +} + +impl PeerConfig { + pub fn new(address: IpAddr) -> Self { + Self { + address, + desired_min_tx: None, + required_min_rx: None, + detect_mult: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + /// Local addresses to bind. Accepts a single address, a list, or a comma-separated string. The daemon currently + /// supports exactly one. + #[serde(deserialize_with = "deserialize_bind")] + pub bind: Vec, + /// Directory of executable state-change hook scripts. + pub hooks_dir: PathBuf, + pub defaults: Defaults, + pub peers: Vec, +} + +impl Default for Config { + fn default() -> Self { + Self { + bind: Vec::new(), + hooks_dir: PathBuf::from(DEFAULT_HOOKS_DIR), + defaults: Defaults::default(), + peers: Vec::new(), + } + } +} + +fn parse_bind_list(s: &str) -> Result, String> { + s.split(|c: char| c == ',' || c.is_whitespace()) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| { + item.parse() + .map_err(|e| format!("invalid bind address `{item}`: {e}")) + }) + .collect() +} + +fn deserialize_bind<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(s) => parse_bind_list(&s).map_err(de::Error::custom), + OneOrMany::Many(items) => items + .iter() + .map(|s| parse_bind_list(s).map_err(de::Error::custom)) + .try_fold(Vec::new(), |mut acc, addrs| { + acc.extend(addrs?); + Ok(acc) + }), + } +} + +impl Config { + pub fn from_yaml(yaml: &str) -> Result { + // An empty (or comment-only) document is a null value, which means "all defaults". + Ok(serde_yaml_ng::from_str::>(yaml)?.unwrap_or_default()) + } + + fn from_file(path: &Path) -> Result { + let yaml = fs::read_to_string(path).map_err(|source| ConfigError::Io { + path: path.to_owned(), + source, + })?; + Self::from_yaml(&yaml).map_err(|source| ConfigError::Parse { + path: path.to_owned(), + source, + }) + } + + /// Load the config file. An explicit `path` must exist; with `None`, [`DEFAULT_CONFIG_PATH`] is used if present and + /// built-in defaults otherwise. + pub fn load(path: Option<&Path>) -> Result { + match path { + Some(path) => Self::from_file(path), + None => match Self::from_file(Path::new(DEFAULT_CONFIG_PATH)) { + Err(ConfigError::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => { + Ok(Self::default()) + } + other => other, + }, + } + } + + /// Override global settings from `BFD_*` variables supplied by `var`. Unset and empty variables are ignored. + pub fn apply_env(&mut self, var: impl Fn(&str) -> Option) -> Result<(), ConfigError> { + let get = |name: &str| var(name).filter(|v| !v.trim().is_empty()); + let env_err = |var: &'static str, message: String| ConfigError::Env { var, message }; + let parse_duration = |name: &'static str, value: String| { + humantime::parse_duration(value.trim()).map_err(|e| env_err(name, e.to_string())) + }; + + if let Some(v) = get(ENV_BIND) { + self.bind = parse_bind_list(&v).map_err(|m| env_err(ENV_BIND, m))?; + } + if let Some(v) = get(ENV_HOOKS_DIR) { + self.hooks_dir = PathBuf::from(v); + } + if let Some(v) = get(ENV_DEFAULTS_DESIRED_MIN_TX) { + self.defaults.desired_min_tx = parse_duration(ENV_DEFAULTS_DESIRED_MIN_TX, v)?; + } + if let Some(v) = get(ENV_DEFAULTS_REQUIRED_MIN_RX) { + self.defaults.required_min_rx = parse_duration(ENV_DEFAULTS_REQUIRED_MIN_RX, v)?; + } + if let Some(v) = get(ENV_DEFAULTS_DETECT_MULT) { + self.defaults.detect_mult = v + .trim() + .parse() + .map_err(|e| env_err(ENV_DEFAULTS_DETECT_MULT, format!("{e}")))?; + } + Ok(()) + } + + /// Apply command line settings: a non-empty `bind` replaces the configured addresses, `peers` are added (with + /// default settings) unless a peer with that address is already configured. + pub fn apply_cli(&mut self, bind: Vec, peers: impl IntoIterator) { + if !bind.is_empty() { + self.bind = bind; + } + for address in peers { + if !self.peers.iter().any(|p| p.address == address) { + self.peers.push(PeerConfig::new(address)); + } + } + } + + /// Validate and apply defaults to each peer. + pub fn resolve(&self) -> Result { + let invalid = |message: String| ConfigError::Invalid(message); + + if self.bind.is_empty() { + return Err(invalid( + "no bind address configured (`bind`, BFD_BIND or --bind)".into(), + )); + } + let mut seen = HashSet::new(); + if let Some(dup) = self.bind.iter().find(|addr| !seen.insert(**addr)) { + return Err(invalid(format!("duplicate bind address {dup}"))); + } + + let mut seen = HashSet::new(); + let peers = self + .peers + .iter() + .map(|peer| { + if !seen.insert(peer.address) { + return Err(invalid(format!("duplicate peer {}", peer.address))); + } + if !self + .bind + .iter() + .any(|b| b.is_ipv4() == peer.address.is_ipv4()) + { + return Err(invalid(format!( + "peer {} has no bind address of the same address family", + peer.address + ))); + } + let what = |field: &str| format!("peer {} {field}", peer.address); + let detect_mult = peer.detect_mult.unwrap_or(self.defaults.detect_mult); + if detect_mult == 0 { + return Err(invalid(format!( + "{} must be at least 1", + what("detect_mult") + ))); + } + Ok(ResolvedPeer { + address: peer.address, + desired_min_tx: to_interval( + peer.desired_min_tx.unwrap_or(self.defaults.desired_min_tx), + &what("desired_min_tx"), + )?, + required_min_rx: to_interval( + peer.required_min_rx + .unwrap_or(self.defaults.required_min_rx), + &what("required_min_rx"), + )?, + detect_mult, + }) + }) + .collect::, _>>()?; + + // Validate the defaults even when no peer uses them, so that a bad value is not latent. + let d = &self.defaults; + to_interval(d.desired_min_tx, "defaults.desired_min_tx")?; + to_interval(d.required_min_rx, "defaults.required_min_rx")?; + if d.detect_mult == 0 { + return Err(invalid("defaults.detect_mult must be at least 1".into())); + } + + Ok(ResolvedConfig { + bind: self.bind.clone(), + hooks_dir: self.hooks_dir.clone(), + peers, + }) + } +} + +/// Zero is rejected for both intervals: a zero Required Min RX Interval is legal in RFC 5880 (it asks the peer to +/// stop sending periodic packets) but is not supported here. +fn to_interval(value: Duration, what: &str) -> Result { + let micros = value.as_micros(); + if micros == 0 { + return Err(ConfigError::Invalid(format!( + "{what} must be greater than zero" + ))); + } + u32::try_from(micros) + .map(BfdInterval::from_micros) + .map_err(|_| { + ConfigError::Invalid(format!( + "{what} of {} is too large (maximum is {} microseconds)", + humantime::format_duration(value), + u32::MAX + )) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedConfig { + pub bind: Vec, + pub hooks_dir: PathBuf, + pub peers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedPeer { + pub address: IpAddr, + pub desired_min_tx: BfdInterval, + pub required_min_rx: BfdInterval, + pub detect_mult: u8, +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |k| map.get(k).cloned() + } + + const FULL: &str = " +bind: 192.0.2.1 +hooks_dir: /tmp/hooks +defaults: + desired_min_tx: 500ms + required_min_rx: 250ms + detect_mult: 5 +peers: + - address: 192.0.2.2 + - address: 192.0.2.3 + desired_min_tx: 1s + detect_mult: 4 +"; + + #[test] + fn parses_full_config() { + let config = Config::from_yaml(FULL).unwrap(); + assert_eq!(config.bind, vec![ip("192.0.2.1")]); + assert_eq!(config.hooks_dir, PathBuf::from("/tmp/hooks")); + assert_eq!(config.defaults.desired_min_tx, Duration::from_millis(500)); + assert_eq!(config.peers.len(), 2); + assert_eq!(config.peers[1].desired_min_tx, Some(Duration::from_secs(1))); + assert_eq!(config.peers[0].detect_mult, None); + } + + #[test] + fn empty_and_comment_only_documents_are_defaults() { + assert_eq!(Config::from_yaml("").unwrap(), Config::default()); + assert_eq!(Config::from_yaml("# nothing\n").unwrap(), Config::default()); + } + + #[test] + fn partial_defaults_keep_remaining_builtin_values() { + let config = Config::from_yaml("defaults:\n detect_mult: 7\n").unwrap(); + assert_eq!(config.defaults.detect_mult, 7); + assert_eq!(config.defaults.desired_min_tx, Duration::from_millis(300)); + } + + #[test] + fn peers_inherit_defaults_and_override() { + let resolved = Config::from_yaml(FULL).unwrap().resolve().unwrap(); + let (a, b) = (resolved.peers[0], resolved.peers[1]); + assert_eq!(a.desired_min_tx, BfdInterval::from_millis(500)); + assert_eq!(a.required_min_rx, BfdInterval::from_millis(250)); + assert_eq!(a.detect_mult, 5); + assert_eq!(b.desired_min_tx, BfdInterval::from_secs(1)); + assert_eq!(b.required_min_rx, BfdInterval::from_millis(250)); + assert_eq!(b.detect_mult, 4); + } + + #[test] + fn bind_accepts_scalar_list_and_comma_string() { + for yaml in [ + "bind: 192.0.2.1", + "bind: [192.0.2.1]", + "bind: \"192.0.2.1\"", + ] { + assert_eq!(Config::from_yaml(yaml).unwrap().bind, vec![ip("192.0.2.1")]); + } + let multi = Config::from_yaml("bind: \"192.0.2.1, 2001:db8::1\"").unwrap(); + assert_eq!(multi.bind, vec![ip("192.0.2.1"), ip("2001:db8::1")]); + let list = Config::from_yaml("bind: [192.0.2.1, \"2001:db8::1\"]").unwrap(); + assert_eq!(list.bind, multi.bind); + } + + #[test] + fn rejects_unknown_fields_and_bad_values() { + assert!(Config::from_yaml("bnid: 192.0.2.1").is_err()); + assert!(Config::from_yaml("defaults:\n desired_min_txx: 1s").is_err()); + assert!(Config::from_yaml("peers:\n - address: 192.0.2.2\n color: red").is_err()); + assert!(Config::from_yaml("bind: not-an-ip").is_err()); + assert!(Config::from_yaml("defaults:\n desired_min_tx: soon").is_err()); + assert!(Config::from_yaml("peers:\n - detect_mult: 3").is_err()); + } + + #[test] + fn yaml_roundtrip() { + let config = Config::from_yaml(FULL).unwrap(); + let again = Config::from_yaml(&serde_yaml_ng::to_string(&config).unwrap()).unwrap(); + assert_eq!(config, again); + } + + #[test] + fn env_overrides_globals() { + let mut config = Config::from_yaml(FULL).unwrap(); + config + .apply_env(env(&[ + (ENV_BIND, "192.0.2.9"), + (ENV_HOOKS_DIR, "/other"), + (ENV_DEFAULTS_DESIRED_MIN_TX, "2s"), + (ENV_DEFAULTS_REQUIRED_MIN_RX, "100ms"), + (ENV_DEFAULTS_DETECT_MULT, "9"), + ])) + .unwrap(); + assert_eq!(config.bind, vec![ip("192.0.2.9")]); + assert_eq!(config.hooks_dir, PathBuf::from("/other")); + assert_eq!(config.defaults.desired_min_tx, Duration::from_secs(2)); + assert_eq!(config.defaults.required_min_rx, Duration::from_millis(100)); + assert_eq!(config.defaults.detect_mult, 9); + // Peers are untouched, and an explicit per-peer value still beats an overridden default. + assert_eq!(config.peers, Config::from_yaml(FULL).unwrap().peers); + } + + #[test] + fn env_bind_accepts_multiple_addresses() { + for value in [ + "192.0.2.1,2001:db8::1", + "192.0.2.1, 2001:db8::1", + "192.0.2.1 2001:db8::1", + ] { + let mut config = Config::default(); + config.apply_env(env(&[(ENV_BIND, value)])).unwrap(); + assert_eq!( + config.bind, + vec![ip("192.0.2.1"), ip("2001:db8::1")], + "{value}" + ); + } + } + + #[test] + fn dist_config_is_valid() { + let config = Config::from_yaml(include_str!("../config.yaml.dist")).unwrap(); + let resolved = config.resolve().unwrap(); + assert_eq!(resolved.peers.len(), 1); + assert_eq!(config.defaults, Defaults::default()); + assert_eq!(config.hooks_dir, PathBuf::from(DEFAULT_HOOKS_DIR)); + } + + #[test] + fn env_ignores_empty_and_unrelated_variables() { + let mut config = Config::from_yaml(FULL).unwrap(); + config + .apply_env(env(&[ + (ENV_BIND, ""), + (ENV_DEFAULTS_DETECT_MULT, " "), + ("BFD_PEERS", "192.0.2.99"), + ("BFD_PEER", "192.0.2.98"), + ("BFD_ADDR", "192.0.2.97"), + ])) + .unwrap(); + assert_eq!(config, Config::from_yaml(FULL).unwrap()); + } + + #[test] + fn env_errors_name_the_variable() { + for (var, value) in [ + (ENV_BIND, "nope"), + (ENV_DEFAULTS_DESIRED_MIN_TX, "fast"), + (ENV_DEFAULTS_DETECT_MULT, "-1"), + (ENV_DEFAULTS_DETECT_MULT, "300"), + ] { + let err = Config::default() + .apply_env(env(&[(var, value)])) + .unwrap_err(); + assert!(err.to_string().contains(var), "{err}"); + } + } + + #[test] + fn cli_bind_replaces_and_peers_merge() { + let mut config = Config::from_yaml(FULL).unwrap(); + config.apply_cli( + vec![ip("192.0.2.7"), ip("2001:db8::7")], + [ip("192.0.2.3"), ip("192.0.2.4"), ip("192.0.2.4")], + ); + assert_eq!(config.bind, vec![ip("192.0.2.7"), ip("2001:db8::7")]); + let addrs: Vec<_> = config.peers.iter().map(|p| p.address).collect(); + assert_eq!( + addrs, + vec![ip("192.0.2.2"), ip("192.0.2.3"), ip("192.0.2.4")] + ); + // The file's settings for an existing peer win over the bare CLI entry. + assert_eq!(config.peers[1].detect_mult, Some(4)); + + let mut config = Config::from_yaml(FULL).unwrap(); + config.apply_cli(Vec::new(), []); + assert_eq!(config.bind, vec![ip("192.0.2.1")]); + } + + #[test] + fn resolve_validation() { + let base = + || Config::from_yaml("bind: 192.0.2.1\npeers:\n - address: 192.0.2.2\n").unwrap(); + assert!(base().resolve().is_ok()); + + let mut c = base(); + c.bind.clear(); + assert!(c.resolve().is_err()); + + let mut c = base(); + c.bind.push(ip("192.0.2.1")); + assert!(c.resolve().is_err()); + + let mut c = base(); + c.peers.push(PeerConfig::new(ip("192.0.2.2"))); + assert!(c + .resolve() + .unwrap_err() + .to_string() + .contains("duplicate peer")); + + let mut c = base(); + c.peers.push(PeerConfig::new(ip("2001:db8::2"))); + assert!(c + .resolve() + .unwrap_err() + .to_string() + .contains("address family")); + c.bind.push(ip("2001:db8::1")); + assert!(c.resolve().is_ok()); + + let mut c = base(); + c.peers[0].detect_mult = Some(0); + assert!(c.resolve().is_err()); + + let mut c = base(); + c.peers[0].desired_min_tx = Some(Duration::ZERO); + assert!(c.resolve().is_err()); + + let mut c = base(); + c.peers[0].required_min_rx = Some(Duration::from_secs(5000)); + assert!(c.resolve().unwrap_err().to_string().contains("too large")); + + let mut c = base(); + c.defaults.detect_mult = 0; + assert!(c.resolve().is_err()); + } + + #[test] + fn zero_peers_is_valid() { + let config = Config::from_yaml("bind: 192.0.2.1").unwrap(); + assert!(config.resolve().unwrap().peers.is_empty()); + } + + #[test] + fn load_requires_explicit_path_to_exist() { + let err = Config::load(Some(Path::new("/nonexistent/rust-bfd.yaml"))).unwrap_err(); + assert!(matches!(err, ConfigError::Io { .. })); + } +} diff --git a/src/events.rs b/src/events.rs index 187f192..ca13bac 100644 --- a/src/events.rs +++ b/src/events.rs @@ -60,9 +60,12 @@ impl Default for ScriptHookSink { impl ScriptHookSink { pub fn new() -> Self { + Self::with_script_dir("/etc/rust-bfd/hooks.d/") + } + pub fn with_script_dir(script_dir: impl Into) -> Self { let (tx_channel, rx_channel) = mpsc::channel(16); Self { - script_dir: "/etc/rust-bfd/hooks.d/".into(), + script_dir: script_dir.into(), tx_channel, rx_channel, } diff --git a/src/lib.rs b/src/lib.rs index 0d988c7..5da552c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod bfd; +pub mod config; pub mod control; pub mod events;