From 1a32d2c19dfad6bba429be33f3f73dbf57293442 Mon Sep 17 00:00:00 2001 From: Zac Mrowicki Date: Thu, 30 Jun 2022 20:23:28 +0000 Subject: [PATCH] netdog: Support versioning in `net.toml` This change adds the ability to handle additional versions of `net.toml` as the project continues to add features to network configuration. A top level `NetConfig` type has been removed in favor of a few traits. Each new version of config is expected to implement these traits, the most important of which converts the network configuration into `WickedInterface` structs which are suitable for serializing directly to file. As part of this change, the `net_config` module has been lightly refactored and split into submodules to make the pieces easier to use among new versions. --- sources/api/netdog/src/interface_name.rs | 14 +- sources/api/netdog/src/main.rs | 13 +- sources/api/netdog/src/net_config.rs | 557 --------------------- sources/api/netdog/src/net_config/dhcp.rs | 31 ++ sources/api/netdog/src/net_config/error.rs | 43 ++ sources/api/netdog/src/net_config/mod.rs | 303 +++++++++++ sources/api/netdog/src/net_config/v1.rs | 274 ++++++++++ sources/api/netdog/src/wicked.rs | 83 ++- 8 files changed, 701 insertions(+), 617 deletions(-) delete mode 100644 sources/api/netdog/src/net_config.rs create mode 100644 sources/api/netdog/src/net_config/dhcp.rs create mode 100644 sources/api/netdog/src/net_config/error.rs create mode 100644 sources/api/netdog/src/net_config/mod.rs create mode 100644 sources/api/netdog/src/net_config/v1.rs diff --git a/sources/api/netdog/src/interface_name.rs b/sources/api/netdog/src/interface_name.rs index 7f7ccd61cb8..397bbb219f3 100644 --- a/sources/api/netdog/src/interface_name.rs +++ b/sources/api/netdog/src/interface_name.rs @@ -10,16 +10,16 @@ use std::ops::Deref; /// InterfaceName can only be created from a string that contains a valid network interface name. /// Validation is handled in the `TryFrom` implementation below. -#[derive(Debug, Eq, PartialEq, Hash, Deserialize)] -#[serde(try_from = "&str")] +#[derive(Clone, Debug, Eq, PartialEq, Hash, Deserialize)] +#[serde(try_from = "String")] pub(crate) struct InterfaceName { inner: String, } -impl TryFrom<&str> for InterfaceName { +impl TryFrom for InterfaceName { type Error = error::Error; - fn try_from(input: &str) -> Result { + fn try_from(input: String) -> Result { // Rust does not treat all Unicode line terminators as starting a new line, so we check for // specific characters here, rather than just counting from lines(). // https://en.wikipedia.org/wiki/Newline#Unicode @@ -67,11 +67,11 @@ impl TryFrom<&str> for InterfaceName { } } -impl TryFrom for InterfaceName { +impl TryFrom<&str> for InterfaceName { type Error = error::Error; - fn try_from(input: String) -> Result { - Self::try_from(input.as_ref()) + fn try_from(input: &str) -> Result { + Self::try_from(input.to_string()) } } diff --git a/sources/api/netdog/src/main.rs b/sources/api/netdog/src/main.rs index 76a5f71751e..e545b20757c 100644 --- a/sources/api/netdog/src/main.rs +++ b/sources/api/netdog/src/main.rs @@ -39,7 +39,6 @@ use dns_lookup::lookup_addr; use envy; use ipnet::IpNet; use lazy_static::lazy_static; -use net_config::NetConfig; use rand::seq::SliceRandom; use rand::thread_rng; use regex::Regex; @@ -53,7 +52,6 @@ use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::process::{self, Command}; use std::str::FromStr; -use wicked::WickedInterface; static RESOLV_CONF: &str = "/etc/resolv.conf"; static KERNEL_HOSTNAME: &str = "/proc/sys/kernel/hostname"; @@ -321,11 +319,11 @@ fn generate_hostname() -> Result<()> { /// Generate configuration for network interfaces. fn generate_net_config() -> Result<()> { let maybe_net_config = if Path::exists(Path::new(DEFAULT_NET_CONFIG_FILE)) { - NetConfig::from_path(DEFAULT_NET_CONFIG_FILE).context(error::NetConfigParseSnafu { + net_config::from_path(DEFAULT_NET_CONFIG_FILE).context(error::NetConfigParseSnafu { path: DEFAULT_NET_CONFIG_FILE, })? } else { - NetConfig::from_command_line(KERNEL_CMDLINE).context(error::NetConfigParseSnafu { + net_config::from_command_line(KERNEL_CMDLINE).context(error::NetConfigParseSnafu { path: KERNEL_CMDLINE, })? }; @@ -338,14 +336,15 @@ fn generate_net_config() -> Result<()> { return Ok(()); } }; + let primary_interface = net_config .primary_interface() .context(error::GetPrimaryInterfaceSnafu)?; write_primary_interface(primary_interface)?; - for (name, config) in net_config.interfaces { - let wicked_interface = WickedInterface::from_config(name, config); - wicked_interface + let wicked_interfaces = net_config.into_wicked_interfaces(); + for interface in wicked_interfaces { + interface .write_config_file() .context(error::InterfaceConfigWriteSnafu)?; } diff --git a/sources/api/netdog/src/net_config.rs b/sources/api/netdog/src/net_config.rs deleted file mode 100644 index c7d680a395a..00000000000 --- a/sources/api/netdog/src/net_config.rs +++ /dev/null @@ -1,557 +0,0 @@ -//! The net_config module contains the strucures needed to deserialize a `net.toml` file. It also -//! includes contains the `FromStr` implementations to create a `NetConfig` from string, like from -//! the kernel command line. -//! -//! These structures are the user-facing options for configuring one or more network interfaces. -use crate::interface_name::InterfaceName; -use indexmap::{indexmap, IndexMap}; -use serde::Deserialize; -use snafu::{ensure, OptionExt, ResultExt}; -use std::collections::HashSet; -use std::convert::TryInto; -use std::fs; -use std::ops::Deref; -use std::path::Path; -use std::str::FromStr; - -static DEFAULT_INTERFACE_PREFIX: &str = "netdog.default-interface="; - -// TODO: support deserializing different versions of this configuration. -// Idea: write a deserializer that uses the `version` field and deserializes the rest of the config -// into an enum with variants for each version, i.e. -// enum NetConfig { -// V1(NetInterfaceV1) -// V2(NetInterfaceV2) -// } -#[derive(Debug, Deserialize)] -pub(crate) struct NetConfig { - pub(crate) version: u8, - // Use an IndexMap to preserve the order of the devices defined in the net.toml. The TOML - // library supports this through a feature making use of IndexMap. Order is important because - // we use the first device in the list as the primary device if the `primary` key isn't set for - // any of the devices. - // - // A custom type is used here that will ensure the validity of the interface name as according - // to the criteria in the linux kernel. See the `interface_name` module for additional details - // on the validation performed. - #[serde(flatten)] - pub(crate) interfaces: IndexMap, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct NetInterface { - // Use this interface as the primary interface for the system - pub(crate) primary: Option, - pub(crate) dhcp4: Option, - pub(crate) dhcp6: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub(crate) enum Dhcp4Config { - DhcpEnabled(bool), - WithOptions(Dhcp4Options), -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Dhcp4Options { - pub(crate) enabled: bool, - pub(crate) optional: Option, - #[serde(rename = "route-metric")] - pub(crate) route_metric: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub(crate) enum Dhcp6Config { - DhcpEnabled(bool), - WithOptions(Dhcp6Options), -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct Dhcp6Options { - pub(crate) enabled: bool, - pub(crate) optional: Option, -} - -impl NetConfig { - /// Create a `NetConfig` from file - pub(crate) fn from_path

(path: P) -> Result> - where - P: AsRef, - { - let path = path.as_ref(); - let net_config_str = - fs::read_to_string(path).context(error::NetConfigReadFailedSnafu { path })?; - let net_config: NetConfig = - toml::from_str(&net_config_str).context(error::NetConfigParseSnafu { path })?; - - ensure!( - net_config.version == 1, - error::InvalidNetConfigSnafu { - reason: "invalid version" - } - ); - - let dhcp_misconfigured = net_config - .interfaces - .values() - .any(|cfg| cfg.dhcp4.is_none() && cfg.dhcp6.is_none()); - ensure!( - !dhcp_misconfigured, - error::InvalidNetConfigSnafu { - reason: "each interface must configure dhcp4 or dhcp6, or both", - } - ); - - let primary_count = net_config - .interfaces - .values() - .filter(|v| v.primary == Some(true)) - .count(); - ensure!( - primary_count <= 1, - error::InvalidNetConfigSnafu { - reason: "multiple primary interfaces defined, expected 1" - } - ); - - if net_config.interfaces.is_empty() { - return Ok(None); - } - - Ok(Some(net_config)) - } - - /// Create a `NetConfig` from string from the kernel command line - pub(crate) fn from_command_line

(path: P) -> Result> - where - P: AsRef, - { - let p = path.as_ref(); - let kernel_cmdline = - fs::read_to_string(p).context(error::KernelCmdlineReadFailedSnafu { path: p })?; - - let mut maybe_interfaces = kernel_cmdline - .split_whitespace() - .filter(|s| s.starts_with(DEFAULT_INTERFACE_PREFIX)); - - let default_interface = match maybe_interfaces.next() { - Some(interface_str) => interface_str - .trim_start_matches(DEFAULT_INTERFACE_PREFIX) - .to_string(), - None => return Ok(None), - }; - - ensure!( - maybe_interfaces.next().is_none(), - error::MultipleDefaultInterfacesSnafu - ); - - let net_config = NetConfig::from_str(&default_interface)?; - Ok(Some(net_config)) - } - - /// Return the primary interface for the system. If none of the interfaces are defined as - /// `primary = true`, we use the first interface in the configuration file. Returns `None` in - /// the case no interfaces are defined. - pub(crate) fn primary_interface(&self) -> Option { - self.interfaces - .iter() - .find(|(_, v)| v.primary == Some(true)) - .or_else(|| self.interfaces.first()) - .map(|(n, _)| n.to_string()) - } -} - -// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= - -/// Allow a simple network configuration definition to be parsed from a string. The expected input -/// string looks like: `interface-name:option1,option2`. The colon is required. Acceptable -/// options are "dhcp4", and "dhcp6". For both options an additional sigil, "?", may be provided -/// to signify that the protocol is optional. "Optional" in this context means that we will not -/// wait for a lease in order to consider the interface operational. -/// -/// An full and sensible example could look like: `eno1:dhcp4,dhcp6?`. This would create an -/// interface configuration for the interface named `eno1`, enable both dhcp4 and dhcp6, and -/// consider a dhcp6 lease optional. -impl FromStr for NetConfig { - type Err = Error; - - fn from_str(s: &str) -> std::result::Result { - let (name, options) = s - .split_once(":") - .context(error::InvalidInterfaceDefSnafu { definition: s })?; - - if options.is_empty() || name.is_empty() { - return error::InvalidInterfaceDefSnafu { definition: s }.fail(); - } - - let name = name.try_into().context(error::InvalidInterfaceNameSnafu)?; - let mut interface_config = NetInterface { - primary: None, - dhcp4: None, - dhcp6: None, - }; - - // Keep track of the options we've parsed, and fail if an option is passed more than once, - // for example "dhcp4,dhcp4?" - let mut provided_options = HashSet::new(); - for option in options.split(',').collect::>() { - if provided_options.contains(option) { - return error::InvalidInterfaceDefSnafu { definition: s }.fail(); - } - - if option.starts_with("dhcp4") { - provided_options.insert("dhcp4"); - interface_config.dhcp4 = Some(Dhcp4Config::from_str(option)?) - } else if option.starts_with("dhcp6") { - provided_options.insert("dhcp6"); - interface_config.dhcp6 = Some(Dhcp6Config::from_str(option)?) - } else { - return error::InvalidInterfaceOptionSnafu { given: option }.fail(); - } - } - - let interfaces = indexmap! {name => interface_config}; - let net_config = NetConfig { - version: 1, - interfaces, - }; - Ok(net_config) - } -} - -/// Parse Dhcp4 configuration from a string. See the `FromStr` impl for `NetConfig` for -/// additional details. -/// -/// The expected input here is a string beginning with `dhcp4`. -impl FromStr for Dhcp4Config { - type Err = Error; - - fn from_str(s: &str) -> std::result::Result { - ensure!( - s.starts_with("dhcp4"), - error::CreateFromStrSnafu { - what: "Dhcp4 options", - given: s - } - ); - - let mut optional = None; - let maybe_sigils = s.trim_start_matches("dhcp4"); - if !maybe_sigils.is_empty() { - let sigils = Sigils::from_str(maybe_sigils)?; - for sigil in &*sigils { - match sigil { - Sigil::Optional => { - optional = Some(true); - } - } - } - } - - let dhcp4_options = Dhcp4Options { - enabled: true, - optional, - route_metric: None, - }; - Ok(Dhcp4Config::WithOptions(dhcp4_options)) - } -} - -/// Parse Dhcp6 configuration from a string. See the `FromStr` impl for `NetConfig` for -/// additional details. -/// -/// The expected input here is a string beginning with `dhcp6`. -impl FromStr for Dhcp6Config { - type Err = Error; - - fn from_str(s: &str) -> std::result::Result { - ensure!( - s.starts_with("dhcp6"), - error::CreateFromStrSnafu { - what: "Dhcp6 options", - given: s - } - ); - - let mut optional = None; - let maybe_sigils = s.trim_start_matches("dhcp6"); - if !maybe_sigils.is_empty() { - let sigils = Sigils::from_str(maybe_sigils)?; - for sigil in &*sigils { - match sigil { - Sigil::Optional => { - optional = Some(true); - } - } - } - } - - let dhcp6_options = Dhcp6Options { - enabled: true, - optional, - }; - Ok(Dhcp6Config::WithOptions(dhcp6_options)) - } -} - -/// A wrapper around the possible sigils meant to configure dhcp4 and dhcp6 for an interface. These -/// sigils will be parsed as part of an interface directive string, e.g. "dhcp4?". Currently only -/// "Optional" is supported ("?"). -#[derive(Debug)] -enum Sigil { - Optional, -} - -#[derive(Debug)] -struct Sigils(Vec); - -// This is mostly for convenience to allow iterating over the contained Vec -impl Deref for Sigils { - type Target = Vec; - - fn deref(&self) -> &Vec { - &self.0 - } -} - -impl FromStr for Sigils { - type Err = Error; - - fn from_str(s: &str) -> std::result::Result { - let mut sigils = Sigils(Vec::new()); - - // `chars()` won't give us grapheme clusters, but we don't support any exotic sigils so - // chars should be fine here - let sigil_chars = s.chars(); - for sigil in sigil_chars { - match sigil { - '?' => sigils.0.push(Sigil::Optional), - _ => { - return error::CreateFromStrSnafu { - what: "sigils", - given: sigil, - } - .fail() - } - } - } - - Ok(sigils) - } -} - -// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= - -mod error { - use crate::interface_name; - use snafu::Snafu; - use std::io; - use std::path::PathBuf; - - #[derive(Debug, Snafu)] - #[snafu(visibility(pub(crate)))] - pub(crate) enum Error { - #[snafu(display("Unable to create '{}', from '{}'", what, given))] - CreateFromStr { what: String, given: String }, - - #[snafu(display( - "Invalid interface definition, expected 'name:option1,option2', got {}", - definition - ))] - InvalidInterfaceDef { definition: String }, - - #[snafu(display("Invalid interface name: {}", source))] - InvalidInterfaceName { source: interface_name::Error }, - - #[snafu(display( - "Invalid interface option, expected 'dhcp4' or 'dhcp6', got '{}'", - given - ))] - InvalidInterfaceOption { given: String }, - - #[snafu(display("Invalid network configuration: {}", reason))] - InvalidNetConfig { reason: String }, - - #[snafu(display("Failed to read kernel command line from '{}': {}", path.display(), source))] - KernelCmdlineReadFailed { path: PathBuf, source: io::Error }, - - #[snafu(display( - "Multiple default interfaces defined on kernel command line, expected 1", - ))] - MultipleDefaultInterfaces, - - #[snafu(display("Failed to read network config from '{}': {}", path.display(), source))] - NetConfigReadFailed { path: PathBuf, source: io::Error }, - - #[snafu(display("Failed to parse network config from '{}': {}", path.display(), source))] - NetConfigParse { - path: PathBuf, - source: toml::de::Error, - }, - } -} - -pub(crate) use error::Error; -type Result = std::result::Result; - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - - fn test_data() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data") - } - - fn cmdline() -> PathBuf { - test_data().join("cmdline") - } - - fn net_config() -> PathBuf { - test_data().join("net_config") - } - - #[test] - fn ok_cmdline() { - let cmdline = cmdline().join("ok"); - assert!(NetConfig::from_command_line(cmdline).unwrap().is_some()); - } - - #[test] - fn multiple_interface_from_cmdline() { - let cmdline = cmdline().join("multiple_interface"); - assert!(NetConfig::from_command_line(cmdline).is_err()) - } - - #[test] - fn no_interfaces_cmdline() { - let cmdline = cmdline().join("no_interfaces"); - assert!(NetConfig::from_command_line(cmdline).unwrap().is_none()) - } - - #[test] - fn invalid_version() { - let bad = net_config().join("bad_version.toml"); - assert!(NetConfig::from_path(bad).is_err()) - } - - #[test] - fn ok_config() { - let ok = net_config().join("net_config.toml"); - assert!(NetConfig::from_path(ok).is_ok()) - } - - #[test] - fn invalid_interface_config() { - let bad = net_config().join("invalid_interface_config.toml"); - assert!(NetConfig::from_path(bad).is_err()) - } - - #[test] - fn invalid_dhcp4_config() { - let bad = net_config().join("invalid_dhcp4_config.toml"); - assert!(NetConfig::from_path(bad).is_err()) - } - - #[test] - fn invalid_dhcp6_config() { - let bad = net_config().join("invalid_dhcp6_config.toml"); - assert!(NetConfig::from_path(bad).is_err()) - } - - #[test] - fn invalid_dhcp_config() { - let ok = net_config().join("invalid_dhcp_config.toml"); - assert!(NetConfig::from_path(ok).is_err()) - } - - #[test] - fn dhcp4_missing_enable() { - let bad = net_config().join("dhcp4_missing_enabled.toml"); - assert!(NetConfig::from_path(bad).is_err()) - } - - #[test] - fn dhcp6_missing_enable() { - let bad = net_config().join("dhcp6_missing_enabled.toml"); - assert!(NetConfig::from_path(bad).is_err()) - } - - #[test] - fn no_interfaces() { - let bad = net_config().join("no_interfaces.toml"); - assert!(NetConfig::from_path(bad).unwrap().is_none()) - } - - #[test] - fn defined_primary_interface() { - let ok_path = net_config().join("net_config.toml"); - let cfg = NetConfig::from_path(ok_path).unwrap().unwrap(); - - let expected = "eno2"; - let actual = cfg.primary_interface().unwrap(); - assert_eq!(expected, actual) - } - - #[test] - fn undefined_primary_interface() { - let ok_path = net_config().join("no_primary.toml"); - let cfg = NetConfig::from_path(ok_path).unwrap().unwrap(); - - let expected = "eno3"; - let actual = cfg.primary_interface().unwrap(); - println!("{}", &actual); - assert_eq!(expected, actual) - } - - #[test] - fn multiple_primary_interfaces() { - let multiple = net_config().join("multiple_primary.toml"); - assert!(NetConfig::from_path(multiple).is_err()) - } - - #[test] - fn ok_interface_from_str() { - let ok = &[ - "eno1:dhcp4,dhcp6", - "eno1:dhcp4,dhcp6?", - "eno1:dhcp4?,dhcp6", - "eno1:dhcp4?,dhcp6?", - "eno1:dhcp6?,dhcp4?", - "eno1:dhcp4", - "eno1:dhcp4?", - "eno1:dhcp6", - "eno1:dhcp6?", - ]; - for ok_str in ok { - assert!(NetConfig::from_str(ok_str).is_ok()) - } - } - - #[test] - fn invalid_interface_from_str() { - let bad = &[ - "", - ":", - "eno1:", - ":dhcp4,dhcp6", - "dhcp4", - "eno1:dhc4", - "eno1:dhcp", - "eno1:dhcp4+", - "eno1:dhcp?", - "eno1:dhcp4?,dhcp4", - "ENO1:DHCP4?,DhCp6", - ]; - for bad_str in bad { - assert!(NetConfig::from_str(bad_str).is_err()) - } - } -} diff --git a/sources/api/netdog/src/net_config/dhcp.rs b/sources/api/netdog/src/net_config/dhcp.rs new file mode 100644 index 00000000000..c9f3c783eef --- /dev/null +++ b/sources/api/netdog/src/net_config/dhcp.rs @@ -0,0 +1,31 @@ +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum Dhcp4ConfigV1 { + DhcpEnabled(bool), + WithOptions(Dhcp4OptionsV1), +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Dhcp4OptionsV1 { + pub(crate) enabled: bool, + pub(crate) optional: Option, + #[serde(rename = "route-metric")] + pub(crate) route_metric: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum Dhcp6ConfigV1 { + DhcpEnabled(bool), + WithOptions(Dhcp6OptionsV1), +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Dhcp6OptionsV1 { + pub(crate) enabled: bool, + pub(crate) optional: Option, +} diff --git a/sources/api/netdog/src/net_config/error.rs b/sources/api/netdog/src/net_config/error.rs new file mode 100644 index 00000000000..771525d68ef --- /dev/null +++ b/sources/api/netdog/src/net_config/error.rs @@ -0,0 +1,43 @@ +use crate::interface_name; +use snafu::Snafu; +use std::io; +use std::path::PathBuf; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +pub(crate) enum Error { + #[snafu(display("Unable to create '{}', from '{}'", what, given))] + CreateFromStr { what: String, given: String }, + + #[snafu(display( + "Invalid interface definition, expected 'name:option1,option2', got {}", + definition + ))] + InvalidInterfaceDef { definition: String }, + + #[snafu(display("Invalid interface name: {}", source))] + InvalidInterfaceName { source: interface_name::Error }, + + #[snafu(display( + "Invalid interface option, expected 'dhcp4' or 'dhcp6', got '{}'", + given + ))] + InvalidInterfaceOption { given: String }, + + #[snafu(display("Invalid network configuration: {}", reason))] + InvalidNetConfig { reason: String }, + + #[snafu(display("Failed to read kernel command line from '{}': {}", path.display(), source))] + KernelCmdlineReadFailed { path: PathBuf, source: io::Error }, + + #[snafu(display("Multiple default interfaces defined on kernel command line, expected 1",))] + MultipleDefaultInterfaces, + + #[snafu(display("Failed to read network config from '{}': {}", path.display(), source))] + NetConfigReadFailed { path: PathBuf, source: io::Error }, + + #[snafu(display("Failed to parse network config: {}", source))] + NetConfigParse { source: toml::de::Error }, +} + +pub(crate) type Result = std::result::Result; diff --git a/sources/api/netdog/src/net_config/mod.rs b/sources/api/netdog/src/net_config/mod.rs new file mode 100644 index 00000000000..37e07368d8b --- /dev/null +++ b/sources/api/netdog/src/net_config/mod.rs @@ -0,0 +1,303 @@ +//! The net_config module contains the strucures needed to deserialize a `net.toml` file. It also +//! includes contains the `FromStr` implementations to create a `NetConfig` from string, like from +//! the kernel command line. +//! +//! These structures are the user-facing options for configuring one or more network interfaces. +mod dhcp; +mod error; +mod v1; + +use crate::wicked::WickedInterface; +pub(crate) use dhcp::{Dhcp4ConfigV1, Dhcp4OptionsV1, Dhcp6ConfigV1, Dhcp6OptionsV1}; +pub(crate) use error::{Error, Result}; +use serde::Deserialize; +use snafu::{ensure, ResultExt}; +use std::fs; +use std::path::Path; +use std::str::FromStr; +pub(crate) use v1::NetConfigV1; + +static DEFAULT_INTERFACE_PREFIX: &str = "netdog.default-interface="; + +/// This trait must be implemented by each new version of network config +pub(crate) trait Interfaces { + /// Returns the primary network interface. + fn primary_interface(&self) -> Option; + + /// Does the config contain any interfaces? + fn has_interfaces(&self) -> bool; + + /// Converts the network config into a list of `WickedInterface` structs, suitable for writing + /// to file + fn into_wicked_interfaces(&self) -> Vec; +} + +impl Interfaces for Box { + fn primary_interface(&self) -> Option { + (**self).primary_interface() + } + + fn has_interfaces(&self) -> bool { + (**self).has_interfaces() + } + + fn into_wicked_interfaces(&self) -> Vec { + (**self).into_wicked_interfaces() + } +} + +/// This private trait must also be implemented by each new version of network config. It is used +/// during the deserialization of the config to validate the configuration, ensuring there are no +/// conflicting options set, etc. +trait Validate { + /// Validate the network configuration + fn validate(&self) -> Result<()>; +} + +impl Validate for Box { + fn validate(&self) -> Result<()> { + (**self).validate() + } +} + +/// Read the network config from file, returning an object that implements the `Interfaces` trait +pub(crate) fn from_path

(path: P) -> Result>> +where + P: AsRef, +{ + let path = path.as_ref(); + let net_config_str = + fs::read_to_string(path).context(error::NetConfigReadFailedSnafu { path })?; + let net_config = deserialize_config(&net_config_str)?; + + if !net_config.has_interfaces() { + return Ok(None); + } + + Ok(Some(net_config)) +} + +/// Deserialize the network config, using the version key to determine which config struct to +/// deserialize into +fn deserialize_config(config_str: &str) -> Result> { + #[derive(Debug, Deserialize)] + struct ConfigToml { + version: u8, + #[serde(flatten)] + interface_config: toml::Value, + } + + let ConfigToml { + version, + interface_config, + } = toml::from_str(config_str).context(error::NetConfigParseSnafu)?; + + let net_config: Box = match version { + 1 => validate_config::(interface_config)?, + _ => { + return error::InvalidNetConfigSnafu { + reason: format!("Unknown network config version: {}", version), + } + .fail() + } + }; + + Ok(net_config) +} + +fn validate_config<'a, I>(config_value: toml::Value) -> Result> +where + I: Interfaces + Validate + Deserialize<'a>, +{ + let config = config_value + .try_into::() + .context(error::NetConfigParseSnafu)?; + config.validate()?; + + Ok(Box::new(config)) +} + +/// Read a network config from the kernel command line +pub(crate) fn from_command_line

(path: P) -> Result>> +where + P: AsRef, +{ + let p = path.as_ref(); + let kernel_cmdline = + fs::read_to_string(p).context(error::KernelCmdlineReadFailedSnafu { path: p })?; + + let mut maybe_interfaces = kernel_cmdline + .split_whitespace() + .filter(|s| s.starts_with(DEFAULT_INTERFACE_PREFIX)); + + let default_interface = match maybe_interfaces.next() { + Some(interface_str) => interface_str + .trim_start_matches(DEFAULT_INTERFACE_PREFIX) + .to_string(), + None => return Ok(None), + }; + + ensure!( + maybe_interfaces.next().is_none(), + error::MultipleDefaultInterfacesSnafu + ); + + let net_config = NetConfigV1::from_str(&default_interface)?; + Ok(Some(Box::new(net_config))) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn test_data() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data") + } + + fn cmdline() -> PathBuf { + test_data().join("cmdline") + } + + fn net_config() -> PathBuf { + test_data().join("net_config") + } + + #[test] + fn ok_cmdline() { + let cmdline = cmdline().join("ok"); + assert!(from_command_line(cmdline).unwrap().is_some()); + } + + #[test] + fn multiple_interface_from_cmdline() { + let cmdline = cmdline().join("multiple_interface"); + assert!(from_command_line(cmdline).is_err()) + } + + #[test] + fn no_interfaces_cmdline() { + let cmdline = cmdline().join("no_interfaces"); + assert!(from_command_line(cmdline).unwrap().is_none()) + } + + #[test] + fn invalid_version() { + let bad = net_config().join("bad_version.toml"); + assert!(from_path(bad).is_err()) + } + + #[test] + fn ok_config() { + let ok = net_config().join("net_config.toml"); + assert!(from_path(ok).is_ok()) + } + + #[test] + fn invalid_interface_config() { + let bad = net_config().join("invalid_interface_config.toml"); + assert!(from_path(bad).is_err()) + } + + #[test] + fn invalid_dhcp4_config() { + let bad = net_config().join("invalid_dhcp4_config.toml"); + assert!(from_path(bad).is_err()) + } + + #[test] + fn invalid_dhcp6_config() { + let bad = net_config().join("invalid_dhcp6_config.toml"); + assert!(from_path(bad).is_err()) + } + + #[test] + fn invalid_dhcp_config() { + let ok = net_config().join("invalid_dhcp_config.toml"); + assert!(from_path(ok).is_err()) + } + + #[test] + fn dhcp4_missing_enable() { + let bad = net_config().join("dhcp4_missing_enabled.toml"); + assert!(from_path(bad).is_err()) + } + + #[test] + fn dhcp6_missing_enable() { + let bad = net_config().join("dhcp6_missing_enabled.toml"); + assert!(from_path(bad).is_err()) + } + + #[test] + fn no_interfaces() { + let bad = net_config().join("no_interfaces.toml"); + assert!(from_path(bad).unwrap().is_none()) + } + + #[test] + fn defined_primary_interface() { + let ok_path = net_config().join("net_config.toml"); + let cfg = from_path(ok_path).unwrap().unwrap(); + + let expected = "eno2"; + let actual = cfg.primary_interface().unwrap(); + assert_eq!(expected, actual) + } + + #[test] + fn undefined_primary_interface() { + let ok_path = net_config().join("no_primary.toml"); + let cfg = from_path(ok_path).unwrap().unwrap(); + + let expected = "eno3"; + let actual = cfg.primary_interface().unwrap(); + println!("{}", &actual); + assert_eq!(expected, actual) + } + + #[test] + fn multiple_primary_interfaces() { + let multiple = net_config().join("multiple_primary.toml"); + assert!(from_path(multiple).is_err()) + } + + #[test] + fn ok_interface_from_str() { + let ok = &[ + "eno1:dhcp4,dhcp6", + "eno1:dhcp4,dhcp6?", + "eno1:dhcp4?,dhcp6", + "eno1:dhcp4?,dhcp6?", + "eno1:dhcp6?,dhcp4?", + "eno1:dhcp4", + "eno1:dhcp4?", + "eno1:dhcp6", + "eno1:dhcp6?", + ]; + for ok_str in ok { + assert!(NetConfigV1::from_str(ok_str).is_ok()) + } + } + + #[test] + fn invalid_interface_from_str() { + let bad = &[ + "", + ":", + "eno1:", + ":dhcp4,dhcp6", + "dhcp4", + "eno1:dhc4", + "eno1:dhcp", + "eno1:dhcp4+", + "eno1:dhcp?", + "eno1:dhcp4?,dhcp4", + "ENO1:DHCP4?,DhCp6", + ]; + for bad_str in bad { + assert!(NetConfigV1::from_str(bad_str).is_err()) + } + } +} diff --git a/sources/api/netdog/src/net_config/v1.rs b/sources/api/netdog/src/net_config/v1.rs new file mode 100644 index 00000000000..76ae273b971 --- /dev/null +++ b/sources/api/netdog/src/net_config/v1.rs @@ -0,0 +1,274 @@ +//! The `v1` module contains the first version of the network configuration and implements the +//! appropriate traits. + +use super::{error, Dhcp4ConfigV1, Dhcp6ConfigV1, Error, Interfaces, Result, Validate}; +use crate::{ + interface_name::InterfaceName, + net_config::{Dhcp4OptionsV1, Dhcp6OptionsV1}, + wicked::{WickedControl, WickedDhcp4, WickedDhcp6, WickedInterface}, +}; +use indexmap::indexmap; +use indexmap::IndexMap; +use serde::Deserialize; +use snafu::{ensure, OptionExt, ResultExt}; +use std::{collections::HashSet, str::FromStr}; +use std::{convert::TryInto, ops::Deref}; + +#[derive(Debug, Deserialize)] +pub(crate) struct NetConfigV1 { + // Use an IndexMap to preserve the order of the devices defined in the net.toml. The TOML + // library supports this through a feature making use of IndexMap. Order is important because + // we use the first device in the list as the primary device if the `primary` key isn't set for + // any of the devices. + // + // A custom type is used here that will ensure the validity of the interface name as according + // to the criteria in the linux kernel. See the `interface_name` module for additional details + // on the validation performed. + #[serde(flatten)] + pub(crate) interfaces: IndexMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct NetInterfaceV1 { + // Use this interface as the primary interface for the system + pub(crate) primary: Option, + pub(crate) dhcp4: Option, + pub(crate) dhcp6: Option, +} + +impl Interfaces for NetConfigV1 { + fn primary_interface(&self) -> Option { + self.interfaces + .iter() + .find(|(_, v)| v.primary == Some(true)) + .or_else(|| self.interfaces.first()) + .map(|(n, _)| n.to_string()) + } + + fn has_interfaces(&self) -> bool { + !self.interfaces.is_empty() + } + + fn into_wicked_interfaces(&self) -> Vec { + let mut wicked_interfaces = Vec::with_capacity(self.interfaces.len()); + for (name, config) in &self.interfaces { + let wicked_dhcp4 = config.dhcp4.clone().map(WickedDhcp4::from); + let wicked_dhcp6 = config.dhcp6.clone().map(WickedDhcp6::from); + let wicked_interface = WickedInterface { + name: name.clone(), + control: WickedControl::default(), + ipv4_dhcp: wicked_dhcp4, + ipv6_dhcp: wicked_dhcp6, + }; + wicked_interfaces.push(wicked_interface) + } + + wicked_interfaces + } +} + +impl Validate for NetConfigV1 { + fn validate(&self) -> Result<()> { + let dhcp_misconfigured = self + .interfaces + .values() + .any(|cfg| cfg.dhcp4.is_none() && cfg.dhcp6.is_none()); + ensure!( + !dhcp_misconfigured, + error::InvalidNetConfigSnafu { + reason: "each interface must configure dhcp4 or dhcp6, or both", + } + ); + + let primary_count = self + .interfaces + .values() + .filter(|v| v.primary == Some(true)) + .count(); + ensure!( + primary_count <= 1, + error::InvalidNetConfigSnafu { + reason: "multiple primary interfaces defined, expected 1" + } + ); + + Ok(()) + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + +/// Allow a simple network configuration definition to be parsed from a string. The expected input +/// string looks like: `interface-name:option1,option2`. The colon is required. Acceptable +/// options are "dhcp4", and "dhcp6". For both options an additional sigil, "?", may be provided +/// to signify that the protocol is optional. "Optional" in this context means that we will not +/// wait for a lease in order to consider the interface operational. +/// +/// An full and sensible example could look like: `eno1:dhcp4,dhcp6?`. This would create an +/// interface configuration for the interface named `eno1`, enable both dhcp4 and dhcp6, and +/// consider a dhcp6 lease optional. +impl FromStr for NetConfigV1 { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + let (name, options) = s + .split_once(":") + .context(error::InvalidInterfaceDefSnafu { definition: s })?; + + if options.is_empty() || name.is_empty() { + return error::InvalidInterfaceDefSnafu { definition: s }.fail(); + } + + let name = name.try_into().context(error::InvalidInterfaceNameSnafu)?; + let mut interface_config = NetInterfaceV1 { + primary: None, + dhcp4: None, + dhcp6: None, + }; + + // Keep track of the options we've parsed, and fail if an option is passed more than once, + // for example "dhcp4,dhcp4?" + let mut provided_options = HashSet::new(); + for option in options.split(',').collect::>() { + if provided_options.contains(option) { + return error::InvalidInterfaceDefSnafu { definition: s }.fail(); + } + + if option.starts_with("dhcp4") { + provided_options.insert("dhcp4"); + interface_config.dhcp4 = Some(Dhcp4ConfigV1::from_str(option)?) + } else if option.starts_with("dhcp6") { + provided_options.insert("dhcp6"); + interface_config.dhcp6 = Some(Dhcp6ConfigV1::from_str(option)?) + } else { + return error::InvalidInterfaceOptionSnafu { given: option }.fail(); + } + } + + let interfaces = indexmap! {name => interface_config}; + let net_config = NetConfigV1 { interfaces }; + Ok(net_config) + } +} + +/// Parse Dhcp4 configuration from a string. See the `FromStr` impl for `NetConfig` for +/// additional details. +/// +/// The expected input here is a string beginning with `dhcp4`. +impl FromStr for Dhcp4ConfigV1 { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + ensure!( + s.starts_with("dhcp4"), + error::CreateFromStrSnafu { + what: "Dhcp4 options", + given: s + } + ); + + let mut optional = None; + let maybe_sigils = s.trim_start_matches("dhcp4"); + if !maybe_sigils.is_empty() { + let sigils = Sigils::from_str(maybe_sigils)?; + for sigil in &*sigils { + match sigil { + Sigil::Optional => { + optional = Some(true); + } + } + } + } + + let dhcp4_options = Dhcp4OptionsV1 { + enabled: true, + optional, + route_metric: None, + }; + Ok(Dhcp4ConfigV1::WithOptions(dhcp4_options)) + } +} + +/// Parse Dhcp6 configuration from a string. See the `FromStr` impl for `NetConfig` for +/// additional details. +/// +/// The expected input here is a string beginning with `dhcp6`. +impl FromStr for Dhcp6ConfigV1 { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + ensure!( + s.starts_with("dhcp6"), + error::CreateFromStrSnafu { + what: "Dhcp6 options", + given: s + } + ); + + let mut optional = None; + let maybe_sigils = s.trim_start_matches("dhcp6"); + if !maybe_sigils.is_empty() { + let sigils = Sigils::from_str(maybe_sigils)?; + for sigil in &*sigils { + match sigil { + Sigil::Optional => { + optional = Some(true); + } + } + } + } + + let dhcp6_options = Dhcp6OptionsV1 { + enabled: true, + optional, + }; + Ok(Dhcp6ConfigV1::WithOptions(dhcp6_options)) + } +} + +/// A wrapper around the possible sigils meant to configure dhcp4 and dhcp6 for an interface. These +/// sigils will be parsed as part of an interface directive string, e.g. "dhcp4?". Currently only +/// "Optional" is supported ("?"). +#[derive(Debug)] +enum Sigil { + Optional, +} + +#[derive(Debug)] +struct Sigils(Vec); + +// This is mostly for convenience to allow iterating over the contained Vec +impl Deref for Sigils { + type Target = Vec; + + fn deref(&self) -> &Vec { + &self.0 + } +} + +impl FromStr for Sigils { + type Err = Error; + + fn from_str(s: &str) -> std::result::Result { + let mut sigils = Sigils(Vec::new()); + + // `chars()` won't give us grapheme clusters, but we don't support any exotic sigils so + // chars should be fine here + let sigil_chars = s.chars(); + for sigil in sigil_chars { + match sigil { + '?' => sigils.0.push(Sigil::Optional), + _ => { + return error::CreateFromStrSnafu { + what: "sigils", + given: sigil, + } + .fail() + } + } + } + + Ok(sigils) + } +} diff --git a/sources/api/netdog/src/wicked.rs b/sources/api/netdog/src/wicked.rs index a9491aceda2..eb3adb8ac3d 100644 --- a/sources/api/netdog/src/wicked.rs +++ b/sources/api/netdog/src/wicked.rs @@ -4,7 +4,7 @@ //! The structures in this module are meant to be created from the user-facing structures in the //! `net_config` module. `Default` implementations for WickedInterface exist here as well. use crate::interface_name::InterfaceName; -use crate::net_config::{Dhcp4Config, Dhcp4Options, Dhcp6Config, Dhcp6Options, NetInterface}; +use crate::net_config::{Dhcp4ConfigV1, Dhcp4OptionsV1, Dhcp6ConfigV1, Dhcp6OptionsV1}; use serde::Serialize; use snafu::ResultExt; use std::fs; @@ -16,19 +16,19 @@ const WICKED_FILE_EXT: &str = "xml"; #[derive(Debug, Serialize, PartialEq)] #[serde(rename = "interface")] pub(crate) struct WickedInterface { - name: InterfaceName, - control: WickedControl, + pub(crate) name: InterfaceName, + pub(crate) control: WickedControl, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "ipv4:dhcp")] - ipv4_dhcp: Option, + pub(crate) ipv4_dhcp: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "ipv6:dhcp")] - ipv6_dhcp: Option, + pub(crate) ipv6_dhcp: Option, } #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "kebab-case")] -struct WickedControl { +pub(crate) struct WickedControl { #[serde(skip_serializing_if = "Option::is_none")] mode: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -122,21 +122,21 @@ struct AddrConfFlags { _f: (), } -impl From for WickedDhcp4 { - fn from(dhcp4: Dhcp4Config) -> Self { +impl From for WickedDhcp4 { + fn from(dhcp4: Dhcp4ConfigV1) -> Self { match dhcp4 { - Dhcp4Config::DhcpEnabled(b) => WickedDhcp4 { + Dhcp4ConfigV1::DhcpEnabled(b) => WickedDhcp4 { enabled: b, _f: (), ..Default::default() }, - Dhcp4Config::WithOptions(o) => WickedDhcp4::from(o), + Dhcp4ConfigV1::WithOptions(o) => WickedDhcp4::from(o), } } } -impl From for WickedDhcp4 { - fn from(options: Dhcp4Options) -> Self { +impl From for WickedDhcp4 { + fn from(options: Dhcp4OptionsV1) -> Self { let mut defer_timeout = None; let mut flags = None; @@ -155,21 +155,21 @@ impl From for WickedDhcp4 { } } -impl From for WickedDhcp6 { - fn from(dhcp6: Dhcp6Config) -> Self { +impl From for WickedDhcp6 { + fn from(dhcp6: Dhcp6ConfigV1) -> Self { match dhcp6 { - Dhcp6Config::DhcpEnabled(b) => WickedDhcp6 { + Dhcp6ConfigV1::DhcpEnabled(b) => WickedDhcp6 { enabled: b, _f: (), ..Default::default() }, - Dhcp6Config::WithOptions(o) => WickedDhcp6::from(o), + Dhcp6ConfigV1::WithOptions(o) => WickedDhcp6::from(o), } } } -impl From for WickedDhcp6 { - fn from(options: Dhcp6Options) -> Self { +impl From for WickedDhcp6 { + fn from(options: Dhcp6OptionsV1) -> Self { let mut defer_timeout = None; let mut flags = None; @@ -188,22 +188,7 @@ impl From for WickedDhcp6 { } impl WickedInterface { - /// Create a WickedInterface given a name and configuration - pub(crate) fn from_config(name: InterfaceName, config: NetInterface) -> Self { - let wicked_dhcp4 = config.dhcp4.map(WickedDhcp4::from); - // As additional options are added for IPV6, implement `From` similar to WickedDhcp4 - let wicked_dhcp6 = config.dhcp6.map(WickedDhcp6::from); - WickedInterface { - name, - control: WickedControl::default(), - ipv4_dhcp: wicked_dhcp4, - ipv6_dhcp: wicked_dhcp6, - } - } - /// Serialize the interface's configuration file - // Consume `self` to enforce that changes aren't made to the interface type after it has been - // written to file pub(crate) fn write_config_file(&self) -> Result<()> { let mut cfg_path = Path::new(WICKED_CONFIG_DIR).join(self.name.to_string()); cfg_path.set_extension(WICKED_FILE_EXT); @@ -244,14 +229,20 @@ type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; - use crate::net_config::NetConfig; + use crate::net_config::{self, Interfaces, NetConfigV1}; use std::path::PathBuf; use std::str::FromStr; fn test_data() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("test_data") - .join("wicked") + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data") + } + + fn wicked_config() -> PathBuf { + test_data().join("wicked") + } + + fn net_config() -> PathBuf { + test_data().join("net_config") } // Test the end-to-end trip: "net config from cmdline -> wicked -> serialized XML" @@ -272,13 +263,13 @@ mod tests { "eno8:dhcp4?,dhcp6?", ]; for ok_str in ok { - let net_config = NetConfig::from_str(&ok_str).unwrap(); + let net_config = NetConfigV1::from_str(&ok_str).unwrap(); - for (name, config) in net_config.interfaces { - let interface = WickedInterface::from_config(name, config); + let wicked_interfaces = net_config.into_wicked_interfaces(); + for interface in wicked_interfaces { let generated = serde_xml_rs::to_string(&interface).unwrap(); - let mut path = test_data().join(interface.name.to_string()); + let mut path = wicked_config().join(interface.name.to_string()); path.set_extension("xml"); let expected = fs::read_to_string(path).unwrap(); @@ -290,15 +281,15 @@ mod tests { // Test the end to end trip: "net config -> wicked -> serialized XML" #[test] fn net_config_to_interface_config() { - let net_config_str: &str = include_str!("../test_data/net_config/net_config.toml"); - let net_config: NetConfig = toml::from_str(&net_config_str).unwrap(); + let net_config_path = net_config().join("net_config.toml"); + let net_config = net_config::from_path(&net_config_path).unwrap().unwrap(); - for (name, config) in net_config.interfaces { - let mut path = test_data().join(&name.to_string()); + let wicked_interfaces = net_config.into_wicked_interfaces(); + for interface in wicked_interfaces { + let mut path = wicked_config().join(interface.name.to_string()); path.set_extension("xml"); let expected = fs::read_to_string(path).unwrap(); - let interface = WickedInterface::from_config(name, config); let generated = serde_xml_rs::to_string(&interface).unwrap(); assert_eq!(expected.trim(), generated)