diff --git a/sources/Cargo.lock b/sources/Cargo.lock index ad107b7d5..ca19e7eed 100644 --- a/sources/Cargo.lock +++ b/sources/Cargo.lock @@ -3654,6 +3654,7 @@ dependencies = [ "aws-smithy-runtime", "aws-smithy-types", "aws-types", + "base64 0.22.1", "bottlerocket-modeled-types", "bottlerocket-settings-models", "bytes", @@ -3671,6 +3672,7 @@ dependencies = [ "serde", "serde_json", "snafu", + "tempfile", "tokio", "tokio-retry", "tokio-rustls 0.24.1", diff --git a/sources/api/pluto/Cargo.toml b/sources/api/pluto/Cargo.toml index b62e2d516..0f5d6e839 100644 --- a/sources/api/pluto/Cargo.toml +++ b/sources/api/pluto/Cargo.toml @@ -16,6 +16,7 @@ fips = ["aws-lc-rs/fips", "aws-smithy-experimental/crypto-aws-lc-fips", "rustls/ source-groups = ["aws-smithy-experimental"] [dependencies] +base64.workspace = true bottlerocket-modeled-types.workspace = true bottlerocket-settings-models.workspace = true bytes.workspace = true @@ -38,6 +39,7 @@ rustls.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true snafu.workspace = true +tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio-retry.workspace = true tokio-rustls.workspace = true diff --git a/sources/api/pluto/src/main.rs b/sources/api/pluto/src/main.rs index 3ca79a56d..c0388e878 100644 --- a/sources/api/pluto/src/main.rs +++ b/sources/api/pluto/src/main.rs @@ -39,15 +39,17 @@ mod hyper_proxy; mod proxy; use api::{settings_view_get, settings_view_set, SettingsViewDelta}; +use base64::Engine; use bottlerocket_modeled_types::{KubernetesClusterDnsIp, KubernetesHostnameOverrideSource}; use imdsclient::ImdsClient; use snafu::{ensure, OptionExt, ResultExt}; use std::fs::File; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Write}; use std::net::IpAddr; -use std::process; +use std::path::Path; use std::str::FromStr; use std::string::String; +use std::{env, process}; // This is the default DNS unless our CIDR block begins with "10." const DEFAULT_DNS_CLUSTER_IP: &str = "10.100.0.10"; @@ -56,6 +58,12 @@ const DEFAULT_10_RANGE_DNS_CLUSTER_IP: &str = "172.20.0.10"; const ENI_MAX_PODS_PATH: &str = "/usr/share/eks/eni-max-pods"; +/// The name of the AWS config file used by pluto. The file is placed in a tempdir, and +/// the contents of settings.aws.config are decoded and written here. +const AWS_CONFIG_FILE: &str = "config.pluto"; +/// The environment variable that specifies the path to the AWS config file. +const AWS_CONFIG_FILE_ENV_VAR: &str = "AWS_CONFIG_FILE"; + mod error { use crate::{api, ec2, eks}; use snafu::Snafu; @@ -73,6 +81,9 @@ mod error { #[snafu(display("Missing AWS region"))] AwsRegion, + #[snafu(display("Unable to decode base64 in of AWS config: {}", source))] + AwsBase64Decode { source: base64::DecodeError }, + #[snafu(display("Failed to parse setting {} as u32: {}", setting, source))] ParseToU32 { setting: String, @@ -130,6 +141,21 @@ mod error { instance_type ))] NoInstanceTypeMaxPods { instance_type: String }, + + #[snafu(display("Unable to create AWS config file '{}': {}", filepath, source))] + CreateAwsConfigFile { + filepath: String, + source: std::io::Error, + }, + + #[snafu(display("Unable to write AWS config file to '{}': {}", filepath, source))] + WriteAwsConfigFile { + filepath: String, + source: std::io::Error, + }, + + #[snafu(display("Unable to create tempdir: {}", source))] + Tempdir { source: std::io::Error }, } } @@ -424,6 +450,31 @@ async fn generate_node_name( Ok(()) } +/// Temporarily copy the yet-to-be-committed settings.aws.config value to a file +/// and set the environment variable AWS_CONFIG_FILE to this file's location. +/// This ensures that subsequent calls via pluto to the AWS SDK will respect settings.aws.config. +fn set_aws_config(aws_k8s_info: &SettingsViewDelta, filepath: &Path) -> Result<()> { + if let Some(config_contents) = settings_view_get!(aws_k8s_info.aws.config) { + // Decode settings.aws.config. + let decoded_bytes = base64::engine::general_purpose::STANDARD + .decode(config_contents.as_bytes()) + .context(error::AwsBase64DecodeSnafu)?; + + // Write the decoded bytes to the provided filepath. + let mut file = File::create(filepath).context(error::CreateAwsConfigFileSnafu { + filepath: filepath.to_str().unwrap(), + })?; + file.write_all(&decoded_bytes) + .context(error::WriteAwsConfigFileSnafu { + filepath: filepath.to_str().unwrap(), + })?; + + env::set_var(AWS_CONFIG_FILE_ENV_VAR, filepath); + } + + Ok(()) +} + async fn run() -> Result<()> { let mut client = ImdsClient::new(); let current_settings = api::get_aws_k8s_info().await.context(error::AwsInfoSnafu)?; @@ -431,6 +482,10 @@ async fn run() -> Result<()> { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let temp_dir = tempfile::tempdir().context(error::TempdirSnafu)?; + let aws_config_file_path = temp_dir.path().join(AWS_CONFIG_FILE); + set_aws_config(&aws_k8s_info, Path::new(&aws_config_file_path))?; + generate_cluster_dns_ip(&mut client, &mut aws_k8s_info).await?; generate_node_ip(&mut client, &mut aws_k8s_info).await?; generate_max_pods(&mut client, &mut aws_k8s_info).await?; @@ -471,6 +526,7 @@ mod test { use super::*; use crate::api::SettingsViewDelta; use api::SettingsView; + use bottlerocket_modeled_types::ValidBase64; use bottlerocket_settings_models::AwsSettingsV1; use httptest::{matchers::*, responders::*, Expectation, Server}; @@ -489,6 +545,58 @@ mod test { assert!(result.is_err()); } + // Because of test parallelization, serialize the AWS config tests such that + // the AWS_CONFIG_FILE env variable is deterministically set or unset. + #[test] + fn test_aws_config_sequential() { + test_set_aws_config(); + test_set_aws_config_is_not_set(); + } + + fn test_set_aws_config() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_file_path = temp_dir.path().join("config.fake"); + + // base64 encoded string: + // [default] + // use_fips_endpoint=false + let config_base64 = + ValidBase64::try_from("W2RlZmF1bHRdCnVzZV9maXBzX2VuZHBvaW50PWZhbHNl").unwrap(); + let input = SettingsViewDelta::from_api_response(SettingsView { + aws: Some(AwsSettingsV1 { + config: Some(config_base64), + ..Default::default() + }), + ..Default::default() + }); + let result = set_aws_config(&input, &temp_file_path); + + assert!(result.is_ok()); + assert!(env::var(AWS_CONFIG_FILE_ENV_VAR).is_ok()); + assert_eq!( + env::var(AWS_CONFIG_FILE_ENV_VAR).unwrap(), + temp_file_path.to_str().unwrap() + ); + + // Remove the env variable such that it's no longer set. + env::remove_var(AWS_CONFIG_FILE_ENV_VAR); + } + + fn test_set_aws_config_is_not_set() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_file_path = temp_dir.path().join("config.fake"); + + let input = SettingsViewDelta::from_api_response(SettingsView { + aws: Some(AwsSettingsV1 { + ..Default::default() + }), + ..Default::default() + }); + let result = set_aws_config(&input, &temp_file_path); + assert!(result.is_ok()); + assert!(env::var(AWS_CONFIG_FILE_ENV_VAR).is_err()); // NotPresent + } + #[tokio::test] async fn test_hostname_override_source() { let server = Server::run(); diff --git a/sources/api/schnauzer/src/helpers/mod.rs b/sources/api/schnauzer/src/helpers/mod.rs index 6d1023817..8ad0e47cf 100644 --- a/sources/api/schnauzer/src/helpers/mod.rs +++ b/sources/api/schnauzer/src/helpers/mod.rs @@ -2,6 +2,7 @@ // be registered with the Handlebars library to assist in manipulating // text at render time. +use base64::Engine; use bottlerocket_modeled_types::{OciDefaultsCapability, OciDefaultsResourceLimitType}; use cidr::AnyIpCidr; use dns_lookup::lookup_host; @@ -535,6 +536,81 @@ pub fn tuf_prefix( Ok(()) } +/// The `aws-config` helper is used to create an AWS config file +/// with `use_fips_endpoint` value set based on if the variant is FIPS enabled. +/// +/// # Fallback +/// +/// If this helper runs and `settings.aws.config` is set or `settings.aws.config` +/// is set to a profile other than "default" the helper will return early, leaving the +/// existing `settings.aws.config` setting in place. +/// +/// # Example +/// +/// The AWS config value is generated via +/// `{{ aws-config settings.aws.config settings.aws.profile }}` +/// +/// This would result in something like: +/// ```toml +/// [default] +/// use_fips_endpoint=false +/// ``` +/// +/// The helper will then base64 encode this content and set as `settings.aws.config`. +pub fn aws_config( + helper: &Helper<'_, '_>, + _: &Handlebars, + _: &Context, + renderctx: &mut RenderContext<'_, '_>, + out: &mut dyn Output, +) -> Result<(), RenderError> { + trace!("Starting aws-config helper"); + let template_name = template_name(renderctx); + + check_param_count(helper, template_name, 2)?; + + let aws_config = get_param(helper, 0)?; + if !aws_config.is_null() { + trace!("settings.aws.config already configured. Exiting aws-config helper early"); + return Ok(()); + } + + // settings.aws.profile may be null. If so, we'll use the "default" profile in constructing + // the AWS config. + let aws_profile = match get_param(helper, 1)? { + Value::Null => "default", + Value::String(s) => s, + _ => { + return Err(RenderError::from( + error::TemplateHelperError::InvalidTemplateValue { + expected: "string", + value: get_param(helper, 1)?.to_owned(), + template: template_name.to_owned(), + }, + )) + } + }; + + if aws_profile != "default" { + return Ok(()); + } + + // construct the base64 encoded AWS config + let aws_config_str = format!( + r#"[default] +use_fips_endpoint={}"#, + fips_enabled() + ); + let new_aws_config = base64::engine::general_purpose::STANDARD.encode(&aws_config_str); + + out.write(&new_aws_config) + .with_context(|_| error::TemplateWriteSnafu { + template: template_name.to_owned(), + })?; + + Ok(()) +} + /// Utility function to determine if a variant is in FIPS mode based /// on /proc/sys/crypto/fips_enabled. fn fips_enabled() -> bool { @@ -1742,6 +1818,63 @@ mod test_tuf_repository { } } +#[cfg(test)] +mod test_aws_config_fips_endpoint { + use super::*; + use handlebars::RenderError; + use serde::Serialize; + use serde_json::json; + + // A thin wrapper around the handlebars render_template method that includes + // setup and registration of helpers + fn setup_and_render_template(tmpl: &str, data: &T) -> Result + where + T: Serialize, + { + let mut aws_config_helper = Handlebars::new(); + aws_config_helper.register_helper("aws-config", Box::new(aws_config)); + + aws_config_helper.render_template(tmpl, data) + } + + const METADATA_TEMPLATE: &str = "{{ aws-config settings.aws.config settings.aws.profile }}"; + + // base64 encoded string: + // [default] + // use_fips_endpoint=false + const EXPECTED_CONFIG_DEFAULT: &str = "W2RlZmF1bHRdCnVzZV9maXBzX2VuZHBvaW50PWZhbHNl"; + + #[test] + fn config_default() { + let result = setup_and_render_template( + METADATA_TEMPLATE, + &json!({"settings": {"aws": {"profile": "default"}}}), + ) + .unwrap(); + assert_eq!(result, EXPECTED_CONFIG_DEFAULT); + } + + #[test] + fn config_already_exists() { + let result = setup_and_render_template( + METADATA_TEMPLATE, + &json!({"settings": {"aws": {"profile": "default", "config": "abc"}}}), + ) + .unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn config_non_default_profile() { + let result = setup_and_render_template( + METADATA_TEMPLATE, + &json!({"settings": {"aws": {"profile": "custom"}}}), + ) + .unwrap(); + assert_eq!(result, ""); + } +} + #[cfg(test)] mod test_host { use super::*; diff --git a/sources/api/schnauzer/src/v1.rs b/sources/api/schnauzer/src/v1.rs index 88c88b93f..4df99755c 100644 --- a/sources/api/schnauzer/src/v1.rs +++ b/sources/api/schnauzer/src/v1.rs @@ -120,6 +120,7 @@ pub fn build_template_registry() -> Result> { template_registry.register_helper("join_node_taints", Box::new(helpers::join_node_taints)); template_registry.register_helper("default", Box::new(helpers::default)); template_registry.register_helper("ecr-prefix", Box::new(helpers::ecr_prefix)); + template_registry.register_helper("aws-config", Box::new(helpers::aws_config)); template_registry.register_helper("tuf-prefix", Box::new(helpers::tuf_prefix)); template_registry.register_helper("metadata-prefix", Box::new(helpers::metadata_prefix)); template_registry.register_helper("host", Box::new(helpers::host)); diff --git a/sources/api/schnauzer/src/v2/import/helpers.rs b/sources/api/schnauzer/src/v2/import/helpers.rs index 0ce7e0b97..204232d86 100644 --- a/sources/api/schnauzer/src/v2/import/helpers.rs +++ b/sources/api/schnauzer/src/v2/import/helpers.rs @@ -35,6 +35,7 @@ fn all_helpers() -> HashMap hashmap! { "ecr-prefix" => helper!(handlebars_helpers::ecr_prefix), + "aws-config" => helper!(handlebars_helpers::aws_config) }, "ecs" => hashmap! {