diff --git a/Cargo.lock b/Cargo.lock index 7d50c8e3..dbe641d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,27 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "afterburn" version = "5.4.3" dependencies = [ "anyhow", + "base64 0.21.2", "cfg-if", "clap", "ipnetwork", + "libflate", "libsystemd", "mailparse", "maplit", "mockito", + "netplan-types", "nix 0.27.1", "openssh-keys", "openssl", @@ -389,6 +398,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.8" @@ -939,6 +957,26 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "libflate" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97822bf791bd4d5b403713886a5fbe8bf49520fe78e323b0dc480ca1a03e50b0" +dependencies = [ + "adler32", + "crc32fast", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a52d3a8bfc85f250440e4424db7d857e241a3aebbbe301f3eb606ab15c39acbf" +dependencies = [ + "rle-decode-fast", +] + [[package]] name = "libsystemd" version = "0.6.0" @@ -1087,6 +1125,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "netplan-types" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb821b15b1ee694bd9f6192c832ab7f355c85e8f325a0e96bc3361a46890825" +dependencies = [ + "serde", +] + [[package]] name = "nix" version = "0.26.3" @@ -1472,6 +1519,12 @@ dependencies = [ "winreg", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rustix" version = "0.37.19" diff --git a/Cargo.toml b/Cargo.toml index 5c1c8e03..f73a720e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,12 +35,15 @@ debug = true [dependencies] anyhow = "1.0" +base64 = "0.21" cfg-if = "1.0" clap = { version = "4", "default_features" = false, "features" = ["std", "cargo", "derive", "error-context", "help", "suggestions", "usage", "wrap_help"] } ipnetwork = ">= 0.17, < 0.21" +libflate = "1.3" libsystemd = ">= 0.2.1, < 0.7.0" mailparse = ">= 0.13, < 0.15" maplit = "1.0" +netplan-types = "0.3" nix = { version = ">= 0.19, < 0.28", "default_features" = false, "features" = [ "mount", "user"] } openssh-keys = ">= 0.5, < 0.7" openssl = ">= 0.10.46, < 0.11" diff --git a/src/cli/multi.rs b/src/cli/multi.rs index 9e32ef03..5aad9ffd 100644 --- a/src/cli/multi.rs +++ b/src/cli/multi.rs @@ -26,6 +26,9 @@ pub struct CliMulti { /// The directory into which network units are written #[arg(long = "network-units", value_name = "path")] network_units_dir: Option, + /// The directory into which netplan configs are written + #[arg(long = "netplan-configs", value_name = "path")] + netplan_config_dir: Option, /// Update SSH keys for the given user #[arg(long = "ssh-keys", value_name = "username")] ssh_keys_user: Option, @@ -41,6 +44,7 @@ impl CliMulti { if self.attributes_file.is_none() && self.network_units_dir.is_none() + && self.netplan_config_dir.is_none() && !self.check_in && self.ssh_keys_user.is_none() && self.hostname_file.is_none() @@ -72,6 +76,11 @@ impl CliMulti { .map_or(Ok(()), |x| metadata.write_network_units(x)) .context("writing network units")?; + // write netplan configs if configured to do so + self.netplan_config_dir + .map_or(Ok(()), |x| metadata.write_netplan_configs(x)) + .context("writing network units")?; + // perform boot check-in. if self.check_in { metadata diff --git a/src/providers/mod.rs b/src/providers/mod.rs index ad5365aa..59e46854 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -200,6 +200,10 @@ pub trait MetadataProvider { Ok(vec![]) } + fn netplan_config(&self) -> Result> { + Ok(None) + } + fn boot_checkin(&self) -> Result<()> { warn!("boot check-in requested, but not supported on this platform"); Ok(()) @@ -295,6 +299,23 @@ pub trait MetadataProvider { } Ok(()) } + + fn write_netplan_configs(&self, netplan_configs_dir: String) -> Result<()> { + let dir_path = Path::new(&netplan_configs_dir); + fs::create_dir_all(dir_path) + .with_context(|| format!("failed to create directory {dir_path:?}"))?; + + // Write a single afterburn `.yaml` netplan config. + if let Some(netplan_config) = &self.netplan_config()? { + let netplan_yaml = serde_yaml::to_string(&netplan_config)?; + let file_path = dir_path.join("afterburn.yaml"); + let mut config_file = File::create(&file_path) + .with_context(|| format!("failed to create file {file_path:?}"))?; + write!(&mut config_file, "{netplan_yaml}") + .with_context(|| format!("failed to write netplan config file {config_file:?}"))?; + } + Ok(()) + } } #[cfg(test)] diff --git a/src/providers/vmware/amd64.rs b/src/providers/vmware/amd64.rs index eabac33c..6355dee8 100644 --- a/src/providers/vmware/amd64.rs +++ b/src/providers/vmware/amd64.rs @@ -4,9 +4,14 @@ use super::VmwareProvider; use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose, Engine as _}; +use libflate::gzip::Decoder; +use std::io::Read; /// Guestinfo key for network kargs. static INITRD_NET_KARGS: &str = "guestinfo.afterburn.initrd.network-kargs"; +static METADATA: &str = "guestinfo.metadata"; +static METADATA_ENCODING: &str = "guestinfo.metadata.encoding"; impl VmwareProvider { /// Build the VMware provider, fetching and caching guestinfo entries. @@ -24,11 +29,24 @@ impl VmwareProvider { vmw_backdoor::probe_backdoor() })?; - let mut erpc = backdoor.open_enhanced_chan()?; + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; let guestinfo_net_kargs = Self::fetch_guestinfo(&mut erpc, INITRD_NET_KARGS)?; + drop(erpc); + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; + + let guestinfo_metadata_raw = Self::fetch_guestinfo(&mut erpc, METADATA)?; + + drop(erpc); + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; + + let guestinfo_metadata_encoding = Self::fetch_guestinfo(&mut erpc, METADATA_ENCODING)?; + let guestinfo_metadata = + parse_metadata(guestinfo_metadata_encoding, guestinfo_metadata_raw)?; + let provider = Self { guestinfo_net_kargs, + guestinfo_metadata, }; slog_scope::trace!("cached vmware provider: {:?}", provider); @@ -39,8 +57,160 @@ impl VmwareProvider { fn fetch_guestinfo(erpc: &mut vmw_backdoor::EnhancedChan, key: &str) -> Result> { let guestinfo = erpc .get_guestinfo(key.as_bytes()) - .context("failed to retrieve network kargs")? + .context(format!("failed to retrieve guestinfo for {}", key))? .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()); Ok(guestinfo) } + + pub fn parse_netplan_config(&self) -> Result> { + if let Some(metadata) = &self.guestinfo_metadata { + // To provide at least some sanity checking, parse the netplan config. + // First check if we have JSON userdata. + let netplan_config: netplan_types::NetplanConfig = + if let Ok(netplan_json_config) = serde_json::from_str(metadata) { + netplan_json_config + } else { + // The JSON parsing error is discarded, + // and if YAML parsing fails as well, we only return this error. + serde_yaml::from_str(metadata)? + }; + // Also check the version to make it easier for the OS to say that + // this mechanism is supported or not. + if netplan_config.network.version != 2 { + bail!("only netplan version 2 supported"); + } + Ok(Some(netplan_config)) + } else { + Ok(None) + } + } + + #[cfg(test)] + pub fn new_from_metadata(metadata: String) -> Result { + Ok(Self { + guestinfo_net_kargs: None, + guestinfo_metadata: Some(metadata), + }) + } +} + +fn parse_metadata( + guestinfo_metadata_encoding: Option, + guestinfo_metadata_raw: Option, +) -> Result> { + let guestinfo_metadata = + if let Some(guestinfo_metadata_encoding_val) = guestinfo_metadata_encoding { + match ( + guestinfo_metadata_encoding_val.as_str(), + guestinfo_metadata_raw, + ) { + ("base64" | "b64", Some(guestinfo_metadata_raw_val)) => { + let decoded = + general_purpose::STANDARD.decode(guestinfo_metadata_raw_val.as_bytes())?; + Some(String::from_utf8(decoded)?) + } + ("gzip+base64" | "gz+b64", Some(guestinfo_metadata_raw_val)) => { + let decoded = + general_purpose::STANDARD.decode(guestinfo_metadata_raw_val.as_bytes())?; + let mut decompressor = Decoder::new(decoded.as_slice())?; + let mut uncompressed = Vec::new(); + decompressor.read_to_end(&mut uncompressed)?; + Some(String::from_utf8(uncompressed)?) + } + ("", guestinfo_metadata_raw) => guestinfo_metadata_raw, + (&_, _) => bail!("unknown guestinfo.metadata.encoding"), + } + } else { + guestinfo_metadata_raw + }; + Ok(guestinfo_metadata) +} + +#[test] +fn test_netplan_json() { + let metadata = r#"{ + "network": { + "version": 2, + "ethernets": { + "nics": { + "match": { + "name": "ens*" + }, + "dhcp4": true + } + } + } + }"#; + let provider = VmwareProvider::new_from_metadata(metadata.to_owned()).unwrap(); + let netplan_config = provider.parse_netplan_config().unwrap(); + let netplan_yaml = serde_yaml::to_string(&netplan_config).unwrap(); + let expected = r#"network: + version: 2 + ethernets: + nics: + match: + name: ens* + dhcp4: true +"#; + assert_eq!(netplan_yaml, expected); +} + +#[test] +fn test_netplan_dhcp() { + let metadata = r#"network: + version: 2 + ethernets: + nics: + match: + name: ens* + dhcp4: true +"#; + let provider = VmwareProvider::new_from_metadata(metadata.to_owned()).unwrap(); + let netplan_config = provider.parse_netplan_config().unwrap(); + let netplan_yaml = serde_yaml::to_string(&netplan_config).unwrap(); + assert_eq!(metadata, netplan_yaml); +} + +#[test] +fn test_metadata_plain_1() { + let guestinfo_metadata_raw = Some("hello".to_owned()); + let parsed = parse_metadata(None, guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); +} + +#[test] +fn test_metadata_plain_2() { + let guestinfo_metadata_raw = Some("hello".to_owned()); + let parsed = parse_metadata(Some("".into()), guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); +} + +#[test] +fn test_metadata_base64() { + let guestinfo_metadata_raw = Some("aGVsbG8=".to_owned()); + let parsed = parse_metadata(Some("base64".into()), guestinfo_metadata_raw.clone()) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); + let parsed_b64 = parse_metadata(Some("b64".into()), guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed_b64, "hello"); +} + +#[test] +fn test_metadata_gzip_base64() { + let guestinfo_metadata_raw = Some("H4sIAAAAAAACA8tIzcnJBwCGphA2BQAAAA==".to_owned()); + let parsed = parse_metadata(Some("gzip+base64".into()), guestinfo_metadata_raw.clone()) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); + let parsed_b64 = parse_metadata(Some("gz+b64".into()), guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed_b64, "hello"); } diff --git a/src/providers/vmware/mod.rs b/src/providers/vmware/mod.rs index be4015d4..3443c411 100644 --- a/src/providers/vmware/mod.rs +++ b/src/providers/vmware/mod.rs @@ -11,6 +11,8 @@ use crate::providers::MetadataProvider; pub struct VmwareProvider { /// External network kargs for initrd. guestinfo_net_kargs: Option, + /// Cloud-Init metadata for netplan YAML + guestinfo_metadata: Option, } // Architecture-specific implementation. @@ -30,4 +32,8 @@ impl MetadataProvider for VmwareProvider { fn rd_network_kargs(&self) -> Result> { Ok(self.guestinfo_net_kargs.clone()) } + + fn netplan_config(&self) -> Result> { + self.parse_netplan_config() + } }