diff --git a/sources/Cargo.lock b/sources/Cargo.lock index 6728d700352..891a497eda7 100644 --- a/sources/Cargo.lock +++ b/sources/Cargo.lock @@ -888,11 +888,13 @@ name = "early-boot-config" version = "0.1.0" dependencies = [ "apiclient", + "base64 0.13.0", "cargo-readme", "http", "log", "reqwest", "serde", + "serde-xml-rs", "serde_json", "simplelog", "snafu", @@ -2554,6 +2556,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-xml-rs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0bf1ba0696ccf0872866277143ff1fd14d22eec235d2b23702f95e6660f7dfa" +dependencies = [ + "log", + "serde", + "thiserror", + "xml-rs", +] + [[package]] name = "serde_derive" version = "1.0.118" @@ -3631,3 +3645,9 @@ dependencies = [ "winapi 0.2.8", "winapi-build", ] + +[[package]] +name = "xml-rs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07db065a5cf61a7e4ba64f29e67db906fb1787316516c4e6e5ff0fea1efcd8a" diff --git a/sources/api/early-boot-config/Cargo.toml b/sources/api/early-boot-config/Cargo.toml index 1acfb874371..7368ff8fcb0 100644 --- a/sources/api/early-boot-config/Cargo.toml +++ b/sources/api/early-boot-config/Cargo.toml @@ -11,11 +11,13 @@ exclude = ["README.md"] [dependencies] apiclient = { path = "../apiclient" } +base64 = "0.13" http = "0.2" log = "0.4" reqwest = { version = "0.10", default-features = false, features = ["blocking"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" +serde-xml-rs = "0.4.1" simplelog = "0.9" snafu = "0.6" tokio = { version = "0.2", default-features = false, features = ["macros", "rt-threaded"] } diff --git a/sources/api/early-boot-config/build.rs b/sources/api/early-boot-config/build.rs index 49828a1c42f..f920bb9c390 100644 --- a/sources/api/early-boot-config/build.rs +++ b/sources/api/early-boot-config/build.rs @@ -4,8 +4,31 @@ use std::env; use std::fs::File; use std::io::Write; use std::path::PathBuf; +use std::process; fn main() { + // The code below emits `cfg` operators to conditionally compile this program based on the + // current variant. + // TODO: Replace this approach when the build system supports ideas like "variant + // tags": https://github.com/bottlerocket-os/bottlerocket/issues/1260 + println!("cargo:rerun-if-env-changed=VARIANT"); + if let Ok(variant) = env::var("VARIANT") { + if variant == "aws-dev" { + println!("cargo:rustc-cfg=bottlerocket_platform=\"aws-dev\""); + } else if variant.starts_with("aws") { + println!("cargo:rustc-cfg=bottlerocket_platform=\"aws\""); + } else if variant.starts_with("vmware") { + println!("cargo:rustc-cfg=bottlerocket_platform=\"vmware\""); + } else { + eprintln!( + "For local builds, you must set the 'VARIANT' environment variable so we know which data \ + provider to build. Valid values are the directories in models/src/variants/, for \ + example 'aws-k8s-1.17'." + ); + process::exit(1); + } + } + // Check for environment variable "SKIP_README". If it is set, // skip README generation if env::var_os("SKIP_README").is_some() { diff --git a/sources/api/early-boot-config/src/main.rs b/sources/api/early-boot-config/src/main.rs index ed8b1836d22..a85e2ab36fa 100644 --- a/sources/api/early-boot-config/src/main.rs +++ b/sources/api/early-boot-config/src/main.rs @@ -15,15 +15,15 @@ Currently, Amazon EC2 is supported through the IMDSv1 HTTP API. Data will be ta #[macro_use] extern crate log; -use http::StatusCode; -use reqwest::blocking::Client; -use serde::Serialize; -use serde_json::json; use simplelog::{Config as LogConfig, LevelFilter, TermLogger, TerminalMode}; -use snafu::{ensure, OptionExt, ResultExt}; -use std::path::Path; +use snafu::{ensure, ResultExt}; +use std::fs; use std::str::FromStr; -use std::{env, fs, process}; +use std::{env, process}; + +mod provider; +mod settings; +use crate::provider::PlatformDataProvider; // TODO // Tests! @@ -39,302 +39,27 @@ const TRANSACTION: &str = "bottlerocket-launch"; // We create it after running successfully. const MARKER_FILE: &str = "/var/lib/bottlerocket/early-boot-config.ran"; -mod error { - use http::StatusCode; - use snafu::Snafu; - - // Taken from pluto. - // Extracts the status code from a reqwest::Error and converts it to a string to be displayed - fn get_bad_status_code(source: &reqwest::Error) -> String { - source - .status() - .as_ref() - .map(|i| i.as_str()) - .unwrap_or("Unknown") - .to_string() - } - - use std::io; - use std::path::PathBuf; - - #[derive(Debug, Snafu)] - #[snafu(visibility = "pub(super)")] - pub(super) enum Error { - #[snafu(display("Error {}ing '{}': {}", method, uri, source))] - Request { - method: String, - uri: String, - source: reqwest::Error, - }, - - #[snafu(display("Response '{}' from '{}': {}", get_bad_status_code(&source), uri, source))] - BadResponse { uri: String, source: reqwest::Error }, - - #[snafu(display("Error {}ing '{}': {}", method, uri, source))] - APIRequest { - method: String, - uri: String, - source: apiclient::Error, - }, - - #[snafu(display("Error {} when {}ing '{}': {}", code, method, uri, response_body))] - Response { - method: String, - uri: String, - code: StatusCode, - response_body: String, - }, - - #[snafu(display( - "Unable to read response body when {}ing '{}' (code {}) - {}", - method, - uri, - code, - source - ))] - ResponseBody { - method: String, - uri: String, - code: StatusCode, - source: reqwest::Error, - }, - - #[snafu(display("Error parsing TOML user data: {}", source))] - TOMLUserDataParse { source: toml::de::Error }, - - #[snafu(display("Data is not a TOML table"))] - UserDataNotTomlTable, - - #[snafu(display("TOML data did not contain 'settings' section"))] - UserDataMissingSettings, - - #[snafu(display("Error serializing TOML to JSON: {}", source))] - SettingsToJSON { source: serde_json::error::Error }, - - #[snafu(display("Error deserializing from JSON: {}", source))] - DeserializeJson { source: serde_json::error::Error }, - - #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] - InputFileRead { path: PathBuf, source: io::Error }, - - #[snafu(display("Instance identity document missing {}", missing))] - IdentityDocMissingData { missing: String }, - - #[snafu(display("Logger setup error: {}", source))] - Logger { source: log::SetLoggerError }, - } -} - -type Result = std::result::Result; - -/// Support for new platforms can be added by implementing this trait. -trait PlatformDataProvider { - /// You should return a list of SettingsJson, representing the settings changes you want to - /// send to the API. - /// - /// This is a list so that handling multiple data sources within a platform can feel more - /// natural; you can also send all changes in one entry if you like. - fn platform_data(&self) -> Result>; -} - -/// Unit struct for AWS so we can implement the PlatformDataProvider trait. -struct AwsDataProvider; - -impl AwsDataProvider { - // Currently only able to get fetch session tokens from `latest` - // FIXME Pin to a date version that supports IMDSv2 once such a date version is available. - const IMDS_TOKEN_ENDPOINT: &'static str = "http://169.254.169.254/latest/api/token"; - - const USER_DATA_FILE: &'static str = "/etc/early-boot-config/user-data"; - const USER_DATA_ENDPOINT: &'static str = "http://169.254.169.254/2018-09-24/user-data"; - const IDENTITY_DOCUMENT_FILE: &'static str = "/etc/early-boot-config/identity-document"; - const IDENTITY_DOCUMENT_ENDPOINT: &'static str = - "http://169.254.169.254/2018-09-24/dynamic/instance-identity/document"; - - /// Helper to fetch an IMDSv2 session token that is valid for 60 seconds. - fn fetch_imds_session_token(client: &Client) -> Result { - let uri = Self::IMDS_TOKEN_ENDPOINT; - let response = client - .put(uri) - .header("X-aws-ec2-metadata-token-ttl-seconds", "60") - .send() - .context(error::Request { method: "PUT", uri })? - .error_for_status() - .context(error::BadResponse { uri })?; - let code = response.status(); - response.text().context(error::ResponseBody { - method: "PUT", - uri, - code, - }) - } - - /// Helper to fetch data from IMDS, preferring an override file if present. - /// - /// IMDS returns a 404 if no user data was given, for example; we return Ok(None) to represent - /// this, otherwise Ok(Some(body)) with the response body. - fn fetch_imds( - file: &str, - client: &Client, - session_token: &str, - uri: &str, - description: &str, - ) -> Result> { - if Path::new(file).exists() { - info!("{} file found at {}, using it", description, file); - return Ok(Some( - fs::read_to_string(file).context(error::InputFileRead { path: file })?, - )); - } - debug!("Requesting {} from {}", description, uri); - let response = client - .get(uri) - .header("X-aws-ec2-metadata-token", session_token) - .send() - .context(error::Request { method: "GET", uri })?; - trace!("IMDS response: {:?}", &response); - - match response.status() { - code @ StatusCode::OK => { - info!("Received {}", description); - let response_body = response.text().context(error::ResponseBody { - method: "GET", - uri, - code, - })?; - trace!("Response text: {:?}", &response_body); - - Ok(Some(response_body)) - } - - // IMDS returns 404 if no user data is given, or if IMDS is disabled, for example - StatusCode::NOT_FOUND => Ok(None), - - code @ _ => { - let response_body = response.text().context(error::ResponseBody { - method: "GET", - uri, - code, - })?; - trace!("Response text: {:?}", &response_body); - - error::Response { - method: "GET", - uri, - code, - response_body, - } - .fail() - } - } - } - - /// Fetches user data, which is expected to be in TOML form and contain a `[settings]` section, - /// returning a SettingsJson representing the inside of that section. - fn user_data(client: &Client, session_token: &str) -> Result> { - let desc = "user data"; - let uri = Self::USER_DATA_ENDPOINT; - let file = Self::USER_DATA_FILE; - - let user_data_str = match Self::fetch_imds(file, client, session_token, uri, desc) { - Err(e) => return Err(e), - Ok(None) => return Ok(None), - Ok(Some(s)) => s, - }; - trace!("Received user data: {}", user_data_str); - - // Remove outer "settings" layer before sending to API - let mut val: toml::Value = - toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; - let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; - let inner = table - .remove("settings") - .context(error::UserDataMissingSettings)?; - - SettingsJson::from_val(&inner, desc).map(|s| Some(s)) - } - - /// Fetches the instance identity, returning a SettingsJson representing the values from the - /// document which we'd like to send to the API - currently just region. - fn identity_document(client: &Client, session_token: &str) -> Result> { - let desc = "instance identity document"; - let uri = Self::IDENTITY_DOCUMENT_ENDPOINT; - let file = Self::IDENTITY_DOCUMENT_FILE; - - let iid_str = match Self::fetch_imds(file, client, session_token, uri, desc) { - Err(e) => return Err(e), - Ok(None) => return Ok(None), - Ok(Some(s)) => s, - }; - trace!("Received instance identity document: {}", iid_str); - - // Grab region from instance identity document. - let iid: serde_json::Value = - serde_json::from_str(&iid_str).context(error::DeserializeJson)?; - let region = iid - .get("region") - .context(error::IdentityDocMissingData { missing: "region" })?; - let val = json!({ "aws": {"region": region} }); - - SettingsJson::from_val(&val, desc).map(|s| Some(s)) +/// This function returns the appropriate data provider for this variant. It exists primarily to +/// keep the ugly bits of conditional compilation out of the main function. +fn create_provider() -> Result> { + #[cfg(bottlerocket_platform = "aws")] + { + Ok(Box::new(provider::aws::AwsDataProvider)) } -} - -impl PlatformDataProvider for AwsDataProvider { - /// Return settings changes from the instance identity document and user data. - fn platform_data(&self) -> Result> { - let mut output = Vec::new(); - let client = Client::new(); - - let session_token = Self::fetch_imds_session_token(&client)?; - - // Instance identity doc first, so the user has a chance to override - match Self::identity_document(&client, &session_token) { - Err(e) => return Err(e), - Ok(None) => warn!("No instance identity document found."), - Ok(Some(s)) => output.push(s), - } - // Optional user-specified configuration / overrides - match Self::user_data(&client, &session_token) { - Err(e) => return Err(e), - Ok(None) => warn!("No user data found."), - Ok(Some(s)) => output.push(s), + #[cfg(bottlerocket_platform = "aws-dev")] + { + use std::path::Path; + if Path::new(provider::local_file::LocalFileDataProvider::USER_DATA_FILE).exists() { + Ok(Box::new(provider::local_file::LocalFileDataProvider)) + } else { + Ok(Box::new(provider::aws::AwsDataProvider)) } - - Ok(output) } -} - -/// This function determines which provider we're currently running on. -fn find_provider() -> Result> { - // FIXME: We need to decide what we're going to do with this in the future; ask each - // provider if they should be used? In what order? - Ok(Box::new(AwsDataProvider)) -} - -/// SettingsJson represents a change that a provider would like to make in the API. -#[derive(Debug)] -struct SettingsJson { - json: String, - desc: String, -} -impl SettingsJson { - /// Construct a SettingsJson from a serializable object and a description of that object, - /// which is used for logging. - /// - /// The serializable object is typically something like a toml::Value or serde_json::Value, - /// since they can be easily deserialized from text input in the platform, and manipulated as - /// desired. - fn from_val(data: &impl Serialize, desc: S) -> Result - where - S: Into, + #[cfg(bottlerocket_platform = "vmware")] { - Ok(Self { - json: serde_json::to_string(&data).context(error::SettingsToJSON)?, - desc: desc.into(), - }) + Ok(Box::new(provider::cdrom::CdromDataProvider)) } } @@ -410,13 +135,21 @@ async fn run() -> Result<()> { info!("early-boot-config started"); // Figure out the current provider - info!("Detecting platform data provider"); - let data_provider = find_provider()?; + let data_provider = create_provider()?; info!("Retrieving platform-specific data"); let uri = &format!("{}?tx={}", API_SETTINGS_URI, TRANSACTION); let method = "PATCH"; - for settings_json in data_provider.platform_data()? { + for settings_json in data_provider + .platform_data() + .context(error::ProviderError)? + { + // Don't send an empty request to the API + if settings_json.json.is_empty() { + warn!("{} was empty", settings_json.desc); + continue; + } + info!("Sending {} to API", settings_json.desc); trace!("Request body: {}", settings_json.json); let (code, response_body) = @@ -454,3 +187,35 @@ async fn main() { process::exit(1); } } + +mod error { + use http::StatusCode; + use snafu::Snafu; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(super) enum Error { + #[snafu(display("Error {}ing '{}': {}", method, uri, source))] + APIRequest { + method: String, + uri: String, + source: apiclient::Error, + }, + + #[snafu(display("Provider error: {}", source))] + ProviderError { source: Box }, + + #[snafu(display("Error {} when {}ing '{}': {}", code, method, uri, response_body))] + Response { + method: String, + uri: String, + code: StatusCode, + response_body: String, + }, + + #[snafu(display("Logger setup error: {}", source))] + Logger { source: log::SetLoggerError }, + } +} + +type Result = std::result::Result; diff --git a/sources/api/early-boot-config/src/provider.rs b/sources/api/early-boot-config/src/provider.rs new file mode 100644 index 00000000000..ae7d8bc41c9 --- /dev/null +++ b/sources/api/early-boot-config/src/provider.rs @@ -0,0 +1,22 @@ +//! The provider module owns the `PlatformDataProvider` trait + +use crate::settings::SettingsJson; + +#[cfg(any(bottlerocket_platform = "aws", bottlerocket_platform = "aws-dev"))] +pub(crate) mod aws; + +#[cfg(bottlerocket_platform = "aws-dev")] +pub(crate) mod local_file; + +#[cfg(bottlerocket_platform = "vmware")] +pub(crate) mod cdrom; + +/// Support for new platforms can be added by implementing this trait. +pub(crate) trait PlatformDataProvider { + /// You should return a list of SettingsJson, representing the settings changes you want to + /// send to the API. + /// + /// This is a list so that handling multiple data sources within a platform can feel more + /// natural; you can also send all changes in one entry if you like. + fn platform_data(&self) -> std::result::Result, Box>; +} diff --git a/sources/api/early-boot-config/src/provider/aws.rs b/sources/api/early-boot-config/src/provider/aws.rs new file mode 100644 index 00000000000..0c9b7dee73f --- /dev/null +++ b/sources/api/early-boot-config/src/provider/aws.rs @@ -0,0 +1,253 @@ +//! The aws module implements the `PlatformDataProvider` trait for gathering userdata on AWS. + +use super::{PlatformDataProvider, SettingsJson}; +use http::StatusCode; +use reqwest::blocking::Client; +use serde_json::json; +use snafu::{OptionExt, ResultExt}; +use std::fs; +use std::path::Path; + +/// Unit struct for AWS so we can implement the PlatformDataProvider trait. +pub(crate) struct AwsDataProvider; + +impl AwsDataProvider { + // Currently only able to get fetch session tokens from `latest` + // FIXME Pin to a date version that supports IMDSv2 once such a date version is available. + const IMDS_TOKEN_ENDPOINT: &'static str = "http://169.254.169.254/latest/api/token"; + + const USER_DATA_ENDPOINT: &'static str = "http://169.254.169.254/2018-09-24/user-data"; + const IDENTITY_DOCUMENT_FILE: &'static str = "/etc/early-boot-config/identity-document"; + const IDENTITY_DOCUMENT_ENDPOINT: &'static str = + "http://169.254.169.254/2018-09-24/dynamic/instance-identity/document"; + + /// Helper to fetch an IMDSv2 session token that is valid for 60 seconds. + fn fetch_imds_session_token(client: &Client) -> Result { + let uri = Self::IMDS_TOKEN_ENDPOINT; + let response = client + .put(uri) + .header("X-aws-ec2-metadata-token-ttl-seconds", "60") + .send() + .context(error::Request { method: "PUT", uri })? + .error_for_status() + .context(error::BadResponse { uri })?; + let code = response.status(); + response.text().context(error::ResponseBody { + method: "PUT", + uri, + code, + }) + } + + /// Helper to fetch data from IMDS, preferring an override file if present. + /// + /// IMDS returns a 404 if no user data was given, for example; we return Ok(None) to represent + /// this, otherwise Ok(Some(body)) with the response body. + fn fetch_imds( + client: &Client, + session_token: &str, + uri: &str, + description: &str, + ) -> Result> { + debug!("Requesting {} from {}", description, uri); + let response = client + .get(uri) + .header("X-aws-ec2-metadata-token", session_token) + .send() + .context(error::Request { method: "GET", uri })?; + trace!("IMDS response: {:?}", &response); + + match response.status() { + code @ StatusCode::OK => { + info!("Received {}", description); + let response_body = response.text().context(error::ResponseBody { + method: "GET", + uri, + code, + })?; + trace!("Response text: {:?}", &response_body); + + Ok(Some(response_body)) + } + + // IMDS returns 404 if no user data is given, or if IMDS is disabled, for example + StatusCode::NOT_FOUND => Ok(None), + + code @ _ => { + let response_body = response.text().context(error::ResponseBody { + method: "GET", + uri, + code, + })?; + trace!("Response text: {:?}", &response_body); + + error::Response { + method: "GET", + uri, + code, + response_body, + } + .fail() + } + } + } + + /// Fetches user data, which is expected to be in TOML form and contain a `[settings]` section, + /// returning a SettingsJson representing the inside of that section. + fn user_data(client: &Client, session_token: &str) -> Result> { + let desc = "user data"; + let uri = Self::USER_DATA_ENDPOINT; + + let user_data_str = match Self::fetch_imds(client, session_token, uri, desc) { + Err(e) => return Err(e), + Ok(None) => return Ok(None), + Ok(Some(s)) => s, + }; + trace!("Received user data: {}", user_data_str); + + // Remove outer "settings" layer before sending to API + let mut val: toml::Value = + toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; + let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; + let inner = table + .remove("settings") + .context(error::UserDataMissingSettings)?; + + let json = SettingsJson::from_val(&inner, desc).context(error::SettingsToJSON)?; + Ok(Some(json)) + } + + /// Fetches the instance identity, returning a SettingsJson representing the values from the + /// document which we'd like to send to the API - currently just region. + fn identity_document(client: &Client, session_token: &str) -> Result> { + let desc = "instance identity document"; + let uri = Self::IDENTITY_DOCUMENT_ENDPOINT; + let file = Self::IDENTITY_DOCUMENT_FILE; + + let iid_str = if Path::new(file).exists() { + info!("{} found at {}, using it", desc, file); + fs::read_to_string(file).context(error::InputFileRead { path: file })? + } else { + match Self::fetch_imds(client, session_token, uri, desc) { + Err(e) => return Err(e), + Ok(None) => return Ok(None), + Ok(Some(s)) => s, + } + }; + trace!("Received instance identity document: {}", iid_str); + + // Grab region from instance identity document. + let iid: serde_json::Value = + serde_json::from_str(&iid_str).context(error::DeserializeJson)?; + let region = iid + .get("region") + .context(error::IdentityDocMissingData { missing: "region" })?; + let val = json!({ "aws": {"region": region} }); + + let json = SettingsJson::from_val(&val, desc).context(error::SettingsToJSON)?; + Ok(Some(json)) + } +} + +impl PlatformDataProvider for AwsDataProvider { + /// Return settings changes from the instance identity document and user data. + fn platform_data(&self) -> std::result::Result, Box> { + let mut output = Vec::new(); + let client = Client::new(); + + let session_token = Self::fetch_imds_session_token(&client)?; + + // Instance identity doc first, so the user has a chance to override + match Self::identity_document(&client, &session_token) { + Err(e) => return Err(e).map_err(Into::into), + Ok(None) => warn!("No instance identity document found."), + Ok(Some(s)) => output.push(s), + } + + // Optional user-specified configuration / overrides + match Self::user_data(&client, &session_token) { + Err(e) => return Err(e).map_err(Into::into), + Ok(None) => warn!("No user data found."), + Ok(Some(s)) => output.push(s), + } + + Ok(output) + } +} + +mod error { + use http::StatusCode; + use snafu::Snafu; + use std::io; + use std::path::PathBuf; + + // Taken from pluto. + // Extracts the status code from a reqwest::Error and converts it to a string to be displayed + fn get_bad_status_code(source: &reqwest::Error) -> String { + source + .status() + .as_ref() + .map(|i| i.as_str()) + .unwrap_or("Unknown") + .to_string() + } + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(crate) enum Error { + #[snafu(display("Response '{}' from '{}': {}", get_bad_status_code(&source), uri, source))] + BadResponse { uri: String, source: reqwest::Error }, + + #[snafu(display("Error deserializing from JSON: {}", source))] + DeserializeJson { source: serde_json::error::Error }, + + #[snafu(display("Instance identity document missing {}", missing))] + IdentityDocMissingData { missing: String }, + + #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] + InputFileRead { path: PathBuf, source: io::Error }, + + #[snafu(display("Error {}ing '{}': {}", method, uri, source))] + Request { + method: String, + uri: String, + source: reqwest::Error, + }, + + #[snafu(display("Error {} when {}ing '{}': {}", code, method, uri, response_body))] + Response { + method: String, + uri: String, + code: StatusCode, + response_body: String, + }, + + #[snafu(display( + "Unable to read response body when {}ing '{}' (code {}) - {}", + method, + uri, + code, + source + ))] + ResponseBody { + method: String, + uri: String, + code: StatusCode, + source: reqwest::Error, + }, + + #[snafu(display("Error serializing TOML to JSON: {}", source))] + SettingsToJSON { source: serde_json::error::Error }, + + #[snafu(display("Error parsing TOML user data: {}", source))] + TOMLUserDataParse { source: toml::de::Error }, + + #[snafu(display("TOML data did not contain 'settings' section"))] + UserDataMissingSettings, + + #[snafu(display("Data is not a TOML table"))] + UserDataNotTomlTable, + } +} + +type Result = std::result::Result; diff --git a/sources/api/early-boot-config/src/provider/cdrom.rs b/sources/api/early-boot-config/src/provider/cdrom.rs new file mode 100644 index 00000000000..d9927691e07 --- /dev/null +++ b/sources/api/early-boot-config/src/provider/cdrom.rs @@ -0,0 +1,237 @@ +//! The cdrom module implements the `PlatformDataProvider` trait for gathering userdata from a +//! mounted CDRom. + +use super::{PlatformDataProvider, SettingsJson}; +use serde::Deserialize; +use snafu::{ensure, OptionExt, ResultExt}; +use std::ffi::OsStr; +use std::fs::{self, File}; +use std::io::BufReader; +use std::path::Path; + +pub(crate) struct CdromDataProvider; + +impl CdromDataProvider { + // This program expects that the CD-ROM is already mounted. Mounting happens elsewhere in a + // systemd unit file + const CD_ROM_MOUNT: &'static str = "/media/cdrom"; + // A mounted CD-ROM may contain an OVF file or a user-supplied file named `user-data` + const USER_DATA_FILENAMES: [&'static str; 5] = [ + "user-data", + "ovf-env.xml", + "OVF-ENV.XML", + "ovf_env.xml", + "OVF_ENV.XML", + ]; + + /// Given the list of acceptable filenames, ensure only 1 exists and parse + /// it for user data + fn user_data() -> Result> { + let mut user_data_files = Self::USER_DATA_FILENAMES + .iter() + .map(|filename| Path::new(Self::CD_ROM_MOUNT).join(filename)) + .filter(|file| file.exists()); + + let user_data_file = match user_data_files.next() { + Some(file) => file, + None => return Ok(None), + }; + + ensure!( + user_data_files.next().is_none(), + error::UserDataFileCount { + location: Self::CD_ROM_MOUNT + } + ); + + // XML files require extra processing, while a user-supplied file should already be in TOML + // format + info!("'{}' exists, using it", user_data_file.display()); + let user_data_str = match user_data_file.extension().and_then(OsStr::to_str) { + Some("xml") | Some("XML") => Self::ovf_user_data(user_data_file)?, + // Since we only look for a specific list of file names, we should never find a file + // with an extension we don't understand. + Some(_) => unreachable!(), + None => fs::read_to_string(&user_data_file).context(error::InputFileRead { + path: user_data_file, + })?, + }; + + if user_data_str.is_empty() { + return Ok(None); + } + trace!("Received user data: {}", user_data_str); + + // Remove outer "settings" layer before sending to API + let mut val: toml::Value = + toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; + let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; + let inner = table + .remove("settings") + .context(error::UserDataMissingSettings)?; + + let json = SettingsJson::from_val(&inner, "user data").context(error::SettingsToJSON)?; + Ok(Some(json)) + } + + /// Read and base64 decode user data contained in an OVF file + // In VMWare, user data is supplied to the host via an XML file. Within + // the XML file, there is a `PropertySection` that contains `Property` elements + // with attributes. User data is base64 encoded inside a `Property` element with + // the attribute "user-data". + // + fn ovf_user_data>(path: P) -> Result { + let path = path.as_ref(); + let file = File::open(path).context(error::InputFileRead { path })?; + let reader = BufReader::new(file); + + // Deserialize the OVF file, dropping everything we don't care about + let ovf: Environment = + serde_xml_rs::from_reader(reader).context(error::XmlDeserialize { path })?; + + // We have seen the keys in the `Property` section be "namespaced" like "oe:key" or + // "of:key". Since we aren't trying to validate the schema beyond the presence of the + // elements we care about, we can ignore the namespacing. An example of this type of + // namespacing can be found in the unit test sample data. `serde_xml_rs` effectively + // ignores these namespaces and returns "key" / "value": + // https://github.com/Rreverser/serde-xml-rs/issues/64#issuecomment=540448434 + let mut base64_str = String::new(); + let user_data_key = "user-data"; + for property in ovf.property_section.properties { + if property.key == user_data_key { + base64_str = property.value; + break; + } + } + + // Base64 decode the &str + let decoded_bytes = base64::decode(&base64_str).context(error::Base64Decode { + base64_string: base64_str.to_string(), + })?; + + // Create a valid utf8 str + let decoded = std::str::from_utf8(&decoded_bytes).context(error::InvalidUTF8 { + base64_string: base64_str.to_string(), + })?; + + Ok(decoded.to_string()) + } +} + +impl PlatformDataProvider for CdromDataProvider { + fn platform_data(&self) -> std::result::Result, Box> { + let mut output = Vec::new(); + + match Self::user_data() { + Err(e) => return Err(e).map_err(Into::into), + Ok(None) => warn!("No user data found."), + Ok(Some(s)) => output.push(s), + } + Ok(output) + } +} + +// =^..^= =^..^= =^..^= =^..^= + +// Minimal expected structure for an OVF file with user data +#[derive(Debug, Deserialize)] +struct Environment { + #[serde(rename = "PropertySection", default)] + pub property_section: PropertySection, +} + +#[derive(Default, Debug, Deserialize)] +struct PropertySection { + #[serde(rename = "Property", default)] + pub properties: Vec, +} + +#[derive(Debug, Deserialize)] +struct Property { + pub key: String, + pub value: String, +} + +// =^..^= =^..^= =^..^= =^..^= + +mod error { + use snafu::Snafu; + use std::io; + use std::path::PathBuf; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(crate) enum Error { + #[snafu(display("Unable to base64 decode string '{}': '{}'", base64_string, source))] + Base64Decode { + base64_string: String, + source: base64::DecodeError, + }, + + #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] + InputFileRead { path: PathBuf, source: io::Error }, + + #[snafu(display( + "Invalid (non-utf8) output from base64 string '{}': {}", + base64_string, + source + ))] + InvalidUTF8 { + base64_string: String, + source: std::str::Utf8Error, + }, + + #[snafu(display("Error serializing TOML to JSON: {}", source))] + SettingsToJSON { source: serde_json::error::Error }, + + #[snafu(display("Error parsing TOML user data: {}", source))] + TOMLUserDataParse { source: toml::de::Error }, + + #[snafu(display("Found multiple user data files in '{}', expected 1", location))] + UserDataFileCount { location: String }, + + #[snafu(display("TOML data did not contain 'settings' section"))] + UserDataMissingSettings, + + #[snafu(display("Data is not a TOML table"))] + UserDataNotTomlTable, + + #[snafu(display("Unable to deserialize XML from: '{}': {}", path.display(), source))] + XmlDeserialize { + path: PathBuf, + source: serde_xml_rs::Error, + }, + } +} + +type Result = std::result::Result; + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + fn test_data() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data") + } + + #[test] + fn test_read_xml_user_data_namespaced_keys() { + let xml = test_data().join("namespaced_keys.xml"); + let expected_user_data = "settings.motd = \"hello\""; + + let actual_user_data = CdromDataProvider::ovf_user_data(xml).unwrap(); + + assert_eq!(actual_user_data, expected_user_data) + } + + #[test] + fn test_read_xml_user_data() { + let xml = test_data().join("ovf-env.xml"); + let expected_user_data = "settings.motd = \"hello\""; + + let actual_user_data = CdromDataProvider::ovf_user_data(xml).unwrap(); + + assert_eq!(actual_user_data, expected_user_data) + } +} diff --git a/sources/api/early-boot-config/src/provider/local_file.rs b/sources/api/early-boot-config/src/provider/local_file.rs new file mode 100644 index 00000000000..4ee63e72b2f --- /dev/null +++ b/sources/api/early-boot-config/src/provider/local_file.rs @@ -0,0 +1,66 @@ +//! The local_file module implements the `PlatformDataProvider` trait for gathering userdata from +//! local file + +use super::{PlatformDataProvider, SettingsJson}; +use snafu::{OptionExt, ResultExt}; +use std::fs; + +pub(crate) struct LocalFileDataProvider; + +impl LocalFileDataProvider { + pub(crate) const USER_DATA_FILE: &'static str = "/etc/early-boot-config/user-data"; +} + +impl PlatformDataProvider for LocalFileDataProvider { + fn platform_data(&self) -> std::result::Result, Box> { + let mut output = Vec::new(); + info!("'{}' exists, using it", Self::USER_DATA_FILE); + + let user_data_str = + fs::read_to_string(Self::USER_DATA_FILE).context(error::InputFileRead { + path: Self::USER_DATA_FILE, + })?; + + if user_data_str.is_empty() { + return Ok(output); + } + + // Remove outer "settings" layer before sending to API + let mut val: toml::Value = + toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; + let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; + let inner = table + .remove("settings") + .context(error::UserDataMissingSettings)?; + + let json = SettingsJson::from_val(&inner, "user data").context(error::SettingsToJSON)?; + output.push(json); + + Ok(output) + } +} + +mod error { + use snafu::Snafu; + use std::io; + use std::path::PathBuf; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(crate) enum Error { + #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] + InputFileRead { path: PathBuf, source: io::Error }, + + #[snafu(display("Error serializing TOML to JSON: {}", source))] + SettingsToJSON { source: serde_json::error::Error }, + + #[snafu(display("Error parsing TOML user data: {}", source))] + TOMLUserDataParse { source: toml::de::Error }, + + #[snafu(display("TOML data did not contain 'settings' section"))] + UserDataMissingSettings, + + #[snafu(display("Data is not a TOML table"))] + UserDataNotTomlTable, + } +} diff --git a/sources/api/early-boot-config/src/settings.rs b/sources/api/early-boot-config/src/settings.rs new file mode 100644 index 00000000000..3ecd9ad3c3b --- /dev/null +++ b/sources/api/early-boot-config/src/settings.rs @@ -0,0 +1,32 @@ +//! The settings module owns the `SettingsJson` struct which contains the JSON settings data being +//! sent to the API. + +use serde::Serialize; + +/// SettingsJson represents a change that a provider would like to make in the API. +#[derive(Debug)] +pub(crate) struct SettingsJson { + pub(crate) json: String, + pub(crate) desc: String, +} + +impl SettingsJson { + /// Construct a SettingsJson from a serializable object and a description of that object, + /// which is used for logging. + /// + /// The serializable object is typically something like a toml::Value or serde_json::Value, + /// since they can be easily deserialized from text input in the platform, and manipulated as + /// desired. + pub(crate) fn from_val( + data: &impl Serialize, + desc: S, + ) -> Result + where + S: Into, + { + Ok(Self { + json: serde_json::to_string(&data)?, + desc: desc.into(), + }) + } +} diff --git a/sources/api/early-boot-config/test_data/namespaced_keys.xml b/sources/api/early-boot-config/test_data/namespaced_keys.xml new file mode 100644 index 00000000000..4a8afafc73b --- /dev/null +++ b/sources/api/early-boot-config/test_data/namespaced_keys.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/sources/api/early-boot-config/test_data/ovf-env.xml b/sources/api/early-boot-config/test_data/ovf-env.xml new file mode 100644 index 00000000000..6b0254f5374 --- /dev/null +++ b/sources/api/early-boot-config/test_data/ovf-env.xml @@ -0,0 +1,9 @@ + + + + + + + + +