diff --git a/sources/api/apiclient/README.md b/sources/api/apiclient/README.md index 253051384..072da2700 100644 --- a/sources/api/apiclient/README.md +++ b/sources/api/apiclient/README.md @@ -5,7 +5,7 @@ Current version: 0.1.0 ## apiclient binary The `apiclient` binary provides high-level methods to interact with the Bottlerocket API. -There's a [set](#set-mode) subcommand for changing settings, an [update](#update-mode) subcommand for updating the host, and an [exec](#exec-mode) subcommand for running commands in host containers. +There's a [set](#set-mode) subcommand for changing settings, a [network configure](#network-configure) subcommand for configuring network settings, an [update](#update-mode) subcommand for updating the host, and an [exec](#exec-mode) subcommand for running commands in host containers. There's also a low-level [raw](#raw-mode) subcommand for direct interaction with the HTTP API. It talks to the Bottlerocket socket by default. @@ -75,6 +75,52 @@ You can use JSON form to set it: apiclient set --json '{"motd": "42"}' ``` +### Network configure + +This allows you to configure network settings by providing a network configuration file. The configuration will be applied at the next boot. + +The `network configure` command accepts input from different sources using URI schemes: + +#### File URI input + +You can specify a local file path using the `file://` URI scheme: + +```shell +apiclient network configure file:///path/to/net.toml +``` + +#### Base64 encoded input + +For inline configuration, you can provide base64-encoded content: + +```shell +apiclient network configure base64:dmVyc2lvbiA9IDIKCltldGgwXQpkaGNwNCA9IHRydWU= +``` + +This is particularly useful for automation and configuration management where you want to embed the network configuration directly in scripts or user data. + +#### Configuration format + +The `net.toml` file uses the same format that netdog supports. Here's an example: + +```toml +version = 2 + +[eth0] +dhcp4 = true +dhcp6 = false + +[eth1] +static4 = ["192.168.1.100/24"] +route = [{to = "default", via = "192.168.1.1"}] +``` + +After configuring the network settings, you'll need to reboot the system for the changes to take effect: + +```shell +apiclient reboot +``` + ### Update mode To start, you can check what updates are available: @@ -347,7 +393,7 @@ The results from each item in the report will be one of: ## apiclient library The apiclient library provides high-level methods to interact with the Bottlerocket API. See -the documentation for submodules [`apply`], [`exec`], [`get`], [`reboot`], [`report`], [`set`], +the documentation for submodules [`apply`], [`exec`], [`get`], [`network`], [`reboot`], [`report`], [`set`], and [`update`] for high-level helpers. For more control, and to handle APIs without high-level wrappers, there are also 'raw' methods diff --git a/sources/api/apiclient/README.tpl b/sources/api/apiclient/README.tpl index 89af5d6bc..c495c447f 100644 --- a/sources/api/apiclient/README.tpl +++ b/sources/api/apiclient/README.tpl @@ -5,7 +5,7 @@ Current version: {{version}} ## apiclient binary The `apiclient` binary provides high-level methods to interact with the Bottlerocket API. -There's a [set](#set-mode) subcommand for changing settings, an [update](#update-mode) subcommand for updating the host, and an [exec](#exec-mode) subcommand for running commands in host containers. +There's a [set](#set-mode) subcommand for changing settings, a [network configure](#network-configure) subcommand for configuring network settings, an [update](#update-mode) subcommand for updating the host, and an [exec](#exec-mode) subcommand for running commands in host containers. There's also a low-level [raw](#raw-mode) subcommand for direct interaction with the HTTP API. It talks to the Bottlerocket socket by default. @@ -75,6 +75,52 @@ You can use JSON form to set it: apiclient set --json '{"motd": "42"}' ``` +### Network configure + +This allows you to configure network settings by providing a network configuration file. The configuration will be applied at the next boot. + +The `network configure` command accepts input from different sources using URI schemes: + +#### File URI input + +You can specify a local file path using the `file://` URI scheme: + +```shell +apiclient network configure file:///path/to/net.toml +``` + +#### Base64 encoded input + +For inline configuration, you can provide base64-encoded content: + +```shell +apiclient network configure base64:dmVyc2lvbiA9IDIKCltldGgwXQpkaGNwNCA9IHRydWU= +``` + +This is particularly useful for automation and configuration management where you want to embed the network configuration directly in scripts or user data. + +#### Configuration format + +The `net.toml` file uses the same format that netdog supports. Here's an example: + +```toml +version = 2 + +[eth0] +dhcp4 = true +dhcp6 = false + +[eth1] +static4 = ["192.168.1.100/24"] +route = [{to = "default", via = "192.168.1.1"}] +``` + +After configuring the network settings, you'll need to reboot the system for the changes to take effect: + +```shell +apiclient reboot +``` + ### Update mode To start, you can check what updates are available: diff --git a/sources/api/apiclient/src/lib.rs b/sources/api/apiclient/src/lib.rs index 87b0d80a4..8d7e7ed2f 100644 --- a/sources/api/apiclient/src/lib.rs +++ b/sources/api/apiclient/src/lib.rs @@ -1,5 +1,5 @@ //! The apiclient library provides high-level methods to interact with the Bottlerocket API. See -//! the documentation for submodules [`apply`], [`exec`], [`get`], [`reboot`], [`report`], [`set`], +//! the documentation for submodules [`apply`], [`exec`], [`get`], [`network`], [`reboot`], [`report`], [`set`], //! and [`update`] for high-level helpers. //! //! For more control, and to handle APIs without high-level wrappers, there are also 'raw' methods @@ -24,6 +24,7 @@ pub mod ephemeral_storage; pub mod exec; pub mod get; pub mod lockdown; +pub mod network; pub mod reboot; pub mod report; pub mod set; diff --git a/sources/api/apiclient/src/main.rs b/sources/api/apiclient/src/main.rs index f73107825..92e22c108 100644 --- a/sources/api/apiclient/src/main.rs +++ b/sources/api/apiclient/src/main.rs @@ -7,7 +7,8 @@ // to the API, which is intended to be reusable by other crates. use apiclient::{ - apply, ephemeral_storage, exec, get, lockdown, reboot, report, set, update, SettingsInput, + apply, ephemeral_storage, exec, get, lockdown, network, reboot, report, set, update, + SettingsInput, }; use log::{info, log_enabled, trace, warn}; use model::ephemeral_storage::{Filesystem, Preference}; @@ -43,12 +44,13 @@ impl Default for Args { } /// Stores the usage mode specified by the user as a subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] enum Subcommand { Apply(ApplyArgs), Exec(ExecArgs), Get(GetArgs), Lockdown(LockdownArgs), + Network(NetworkSubcommand), Raw(RawArgs), Reboot(RebootArgs), Set(SetArgs), @@ -58,13 +60,13 @@ enum Subcommand { } /// Stores user-supplied arguments for the 'apply' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct ApplyArgs { input_sources: Vec, } /// Stores user-supplied arguments for the 'exec' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct ExecArgs { command: Vec, target: String, @@ -72,7 +74,7 @@ struct ExecArgs { } /// Stores user-supplied arguments for the 'get' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] enum GetArgs { Prefixes { include: Vec, @@ -83,7 +85,7 @@ enum GetArgs { } /// Stores user-supplied arguments for the 'raw' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct RawArgs { method: String, uri: String, @@ -91,11 +93,11 @@ struct RawArgs { } /// Stores user-supplied arguments for the 'lockdown' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct LockdownArgs {} /// Stores user-supplied arguments for the 'reboot' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct RebootArgs {} /// Stores a vector of user-supplied key-value pairs for the 'set' subcommand. @@ -105,14 +107,14 @@ pub struct SetKeyPairSettings { } /// Stores user-supplied arguments for the 'set' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] enum SetArgs { Simple(Vec), Json(serde_json::Value), } /// Stores the 'update' subcommand specified by the user. -#[derive(Debug)] +#[derive(Debug, PartialEq)] enum UpdateSubcommand { Check(UpdateCheckArgs), Apply(UpdateApplyArgs), @@ -120,7 +122,7 @@ enum UpdateSubcommand { } /// The available 'report' subcommands. -#[derive(Debug)] +#[derive(Debug, PartialEq)] enum ReportSubcommand { Cis(CisReportArgs), CisK8s(CisReportArgs), @@ -128,35 +130,35 @@ enum ReportSubcommand { } /// Stores common user-supplied arguments for the cis report subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct CisReportArgs { level: Option, format: Option, } /// Stores common user-supplied arguments for the fips report subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct FipsReportArgs { format: Option, } /// Stores user-supplied arguments for the 'update check' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct UpdateCheckArgs {} /// Stores user-supplied arguments for the 'update apply' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct UpdateApplyArgs { check: bool, reboot: bool, } /// Stores user-supplied arguments for the 'update cancel' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct UpdateCancelArgs {} /// Stores the 'ephemeral-storage' subcommand specified by the user. -#[derive(Debug)] +#[derive(Debug, PartialEq)] enum EphemeralStorageSubcommand { Init(EphemeralStorageInitArgs), Bind(EphemeralStorageBindArgs), @@ -166,7 +168,7 @@ enum EphemeralStorageSubcommand { } /// Stores user-supplied arguments for the 'ephemeral-storage init' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct EphemeralStorageInitArgs { disks: Option>, ebs_volumes: Option>, @@ -175,16 +177,28 @@ struct EphemeralStorageInitArgs { } /// Stores user-supplied arguments for the 'ephemeral-storage bind' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct EphemeralStorageBindArgs { targets: Vec, } /// Stores user-supplied arguments for the 'ephemeral-storage list-disks/list-ebs-volumes/list-dirs' subcommand. -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct EphemeralStorageFormatArgs { format: Option, } +/// Stores the 'network' subcommand specified by the user. +#[derive(Debug, PartialEq)] +enum NetworkSubcommand { + Configure(NetworkConfigureArgs), +} + +/// Stores user-supplied arguments for the 'network configure' subcommand. +#[derive(Debug, PartialEq)] +struct NetworkConfigureArgs { + input_source: Option, +} + /// Informs the user about proper usage of the program and exits. fn usage() -> ! { let msg = &format!( @@ -203,6 +217,8 @@ fn usage() -> ! { or from stdin. get Retrieve and print settings. set Changes settings and applies them to the system. + network configure Configures network settings from net.toml files at + given URIs. update check Prints information about available updates. update apply Applies available updates. update cancel Deactivates an applied update. @@ -248,6 +264,13 @@ fn usage() -> ! { If neither prefixes nor URI are specified, get will show settings and OS info. + network configure options: + [ URI ] URI to a network configuration file (TOML format) + to apply. Supports file:// and base64: URI schemes. + If no URI is specified, reads from stdin. + Configuration is written to /.bottlerocket/net.toml and + validated at next boot by netdog. + set options: KEY=VALUE [KEY=VALUE ...] The settings you want to set. For example: settings.motd="hi there" settings.ecs.cluster=example @@ -376,8 +399,8 @@ fn parse_args(args: impl Iterator) -> (Args, Subcommand) { } // Subcommands - "raw" | "apply" | "exec" | "get" | "lockdown" | "reboot" | "report" | "set" - | "update" | "ephemeral-storage" + "raw" | "apply" | "exec" | "get" | "lockdown" | "network" | "reboot" | "report" + | "set" | "update" | "ephemeral-storage" if subcommand.is_none() && !arg.starts_with('-') => { subcommand = Some(arg) @@ -395,6 +418,7 @@ fn parse_args(args: impl Iterator) -> (Args, Subcommand) { Some("exec") => (global_args, parse_exec_args(subcommand_args)), Some("get") => (global_args, parse_get_args(subcommand_args)), Some("lockdown") => (global_args, parse_lockdown_args(subcommand_args)), + Some("network") => (global_args, parse_network_args(subcommand_args)), Some("reboot") => (global_args, parse_reboot_args(subcommand_args)), Some("report") => (global_args, parse_report_args(subcommand_args)), Some("set") => (global_args, parse_set_args(subcommand_args)), @@ -564,6 +588,42 @@ fn parse_lockdown_args(args: Vec) -> Subcommand { } Subcommand::Lockdown(LockdownArgs {}) } +/// Parses the desired subcommand of 'network'. +fn parse_network_args(args: Vec) -> Subcommand { + let mut subcommand = None; + let mut subcommand_args = Vec::new(); + + for arg in args.into_iter() { + match arg.as_ref() { + // Subcommands + "configure" if subcommand.is_none() => subcommand = Some(arg), + + // Other arguments are passed to the subcommand parser + _ => subcommand_args.push(arg), + } + } + + match subcommand.as_deref() { + Some("configure") => parse_network_configure_args(subcommand_args), + _ => usage_msg("Missing or unknown subcommand for 'network'"), + } +} + +/// Parses arguments for the 'network configure' subcommand. +fn parse_network_configure_args(args: Vec) -> Subcommand { + let mut input_source = None; + + for arg in args.into_iter() { + if input_source.is_some() { + usage_msg("apiclient network configure takes only one input source URI.") + } + input_source = Some(arg); + } + + Subcommand::Network(NetworkSubcommand::Configure(NetworkConfigureArgs { + input_source, + })) +} /// Parses arguments for the 'reboot' subcommand. fn parse_reboot_args(args: Vec) -> Subcommand { @@ -1084,6 +1144,17 @@ async fn run() -> Result<()> { .context(error::LockdownSnafu)?; } + Subcommand::Network(subcommand) => match subcommand { + NetworkSubcommand::Configure(configure_args) => { + let content = network::get_content(configure_args.input_source) + .await + .context(error::NetworkGetContentSnafu)?; + network::configure(&args.socket_path, content) + .await + .context(error::NetworkConfigureSnafu)?; + } + }, + Subcommand::Reboot(_reboot) => { reboot::reboot(&args.socket_path) .await @@ -1252,7 +1323,9 @@ async fn main() { } mod error { - use apiclient::{apply, ephemeral_storage, exec, get, lockdown, reboot, report, set, update}; + use apiclient::{ + apply, ephemeral_storage, exec, get, lockdown, network, reboot, report, set, update, + }; use snafu::Snafu; #[derive(Debug, Snafu)] @@ -1272,6 +1345,11 @@ mod error { #[snafu(display("Failed to lockdown: {}", source))] Lockdown { source: lockdown::Error }, + #[snafu(display("Failed to get network configuration content: {}", source))] + NetworkGetContent { source: network::Error }, + + #[snafu(display("Failed to configure network: {}", source))] + NetworkConfigure { source: network::Error }, #[snafu(display("Failed to reboot: {}", source))] Reboot { source: reboot::Error }, @@ -1515,4 +1593,59 @@ mod tests { _ => panic!("Expected Get with Prefixes"), } } + + #[test] + fn test_network_configure_parsing_stdin() { + // Test that network configure with no arguments defaults to stdin + let (global_args, subcommand) = parse_command_line("apiclient network configure"); + + // Test global arguments match expected defaults + assert_eq!(global_args.log_level, LevelFilter::Info); + assert_eq!(global_args.socket_path, "/run/api.sock"); + + // Test network configure subcommand with no input source (stdin) + let expected = Subcommand::Network(NetworkSubcommand::Configure(NetworkConfigureArgs { + input_source: None, + })); + assert_eq!(subcommand, expected); + } + + #[test_case("apiclient network configure file:///tmp/net.toml", + global_args!(LevelFilter::Info), + "file:///tmp/net.toml"; + "network configure with file URI")] + #[test_case("apiclient network configure base64:dmVyc2lvbiA9IDIKCltldGgwXQpkaGNwNCA9IHRydWU=", + global_args!(LevelFilter::Info), + "base64:dmVyc2lvbiA9IDIKCltldGgwXQpkaGNwNCA9IHRydWU="; + "network configure with base64 URI")] + #[test_case("apiclient -v network configure file:///tmp/net.toml", + global_args!(LevelFilter::Debug), + "file:///tmp/net.toml"; + "verbose flag with network configure")] + #[test_case("apiclient --log-level error network configure base64:test", + global_args!(LevelFilter::Error), + "base64:test"; + "log level with network configure")] + #[test_case("apiclient --socket-path /tmp/custom.sock network configure file:///etc/net.toml", + global_args!(LevelFilter::Info, "/tmp/custom.sock"), + "file:///etc/net.toml"; + "custom socket path with network configure")] + fn test_network_configure_parsing( + cmd_str: &str, + expected_args: Args, + expected_input_source: &str, + ) { + // Given a command line string for network configure + let (global_args, subcommand) = parse_command_line(cmd_str); + + // Test global arguments match expected values + assert_eq!(global_args.log_level, expected_args.log_level); + assert_eq!(global_args.socket_path, expected_args.socket_path); + + // Test network configure subcommand matches expected + let expected = Subcommand::Network(NetworkSubcommand::Configure(NetworkConfigureArgs { + input_source: Some(expected_input_source.to_string()), + })); + assert_eq!(subcommand, expected); + } } diff --git a/sources/api/apiclient/src/network.rs b/sources/api/apiclient/src/network.rs new file mode 100644 index 000000000..19d2d5282 --- /dev/null +++ b/sources/api/apiclient/src/network.rs @@ -0,0 +1,295 @@ +//! This module implements network configuration API calls. +//! It supports sourcing `net.toml` configuration files from the filesystem or base64 encoded strings. + +use base64::{engine, Engine}; +use snafu::{OptionExt, ResultExt}; +use std::path::Path; +use tokio::io::AsyncReadExt; +use url::Url; + +/// Configures network settings by sending the provided content to the API server. +/// +/// The configuration will be applied at the next boot - a reboot is required for changes to take effect. +pub async fn configure

(socket_path: P, content: String) -> Result<()> +where + P: AsRef, +{ + let uri = "/actions/network/configure"; + let method = "POST"; + let (_status, _body) = crate::raw_request(&socket_path, uri, method, Some(content)) + .await + .context(error::ConfigureRequestSnafu { uri, method })?; + + Ok(()) +} + +/// Retrieves the network configuration content from the given source URI. +/// +/// Supports file:// and base64: URI schemes. If no input source is provided, reads from stdin. +pub async fn get_content(input_source: Option) -> Result +where + S: Into, +{ + match input_source { + Some(source) => get_content_from_source(source.into()).await, + None => get_content_with_stdin(tokio::io::stdin()).await, + } +} + +/// Reads all content from an async reader into a string. +/// +/// Generic reader interface allows flexible input sources and testing with mock data. +async fn get_content_with_stdin(mut reader: R) -> Result +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut output = String::new(); + reader + .read_to_string(&mut output) + .await + .context(error::StdinReadSnafu)?; + Ok(output) +} + +/// Retrieves network configuration content from a source URI. +/// +/// Supports file:// and base64: URI schemes. +async fn get_content_from_source(input_source: String) -> Result { + if let Some(base64_data) = input_source.strip_prefix("base64:") { + return get_content_from_base64(base64_data, &input_source); + } + + let uri = Url::parse(&input_source).context(error::UriSnafu { + input_source: &input_source, + })?; + + match uri.scheme() { + "file" => get_content_from_file(uri, &input_source).await, + scheme => error::UnsupportedUriSchemeSnafu { + input_source: &input_source, + scheme, + } + .fail(), + } +} + +/// Decodes and returns content from a base64-encoded string. +fn get_content_from_base64(base64_data: &str, input_source: &str) -> Result { + let decoded_bytes = engine::general_purpose::STANDARD + .decode(base64_data.as_bytes()) + .context(error::Base64DecodeSnafu { input_source })?; + + String::from_utf8(decoded_bytes).context(error::Base64Utf8Snafu { input_source }) +} + +/// Reads content from a file URI. +async fn get_content_from_file(uri: Url, input_source: &str) -> Result { + let path = uri + .to_file_path() + .ok() + .context(error::FileUriSnafu { input_source })?; + tokio::fs::read_to_string(path) + .await + .context(error::FileReadSnafu { input_source }) +} + +mod error { + use snafu::Snafu; + + #[derive(Debug, Snafu)] + #[snafu(visibility(pub(super)))] + pub enum Error { + #[snafu(display("Failed to decode base64 from '{}': {}", input_source, source))] + Base64Decode { + input_source: String, + source: base64::DecodeError, + }, + + #[snafu(display( + "Base64 content from '{}' is not valid UTF-8: {}", + input_source, + source + ))] + Base64Utf8 { + input_source: String, + source: std::string::FromUtf8Error, + }, + + #[snafu(display("Failed to {} network configuration to '{}': {}", method, uri, source))] + ConfigureRequest { + uri: String, + method: String, + #[snafu(source(from(crate::Error, Box::new)))] + source: Box, + }, + + #[snafu(display("Failed to read given file '{}': {}", input_source, source))] + FileRead { + input_source: String, + source: std::io::Error, + }, + + #[snafu(display("Invalid file URI '{}'", input_source))] + FileUri { input_source: String }, + + #[snafu(display("Failed to read from stdin: {}", source))] + StdinRead { source: std::io::Error }, + + #[snafu(display( + "Unsupported URI scheme '{}' in '{}'. Only file:// and base64: schemes are supported", + scheme, + input_source + ))] + UnsupportedUriScheme { + input_source: String, + scheme: String, + }, + + #[snafu(display("Invalid URI '{}': {}", input_source, source))] + Uri { + input_source: String, + source: url::ParseError, + }, + } +} + +pub use error::Error; +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine, Engine}; + use test_case::test_case; + + #[tokio::test] + async fn test_get_content_stdin() { + use std::io::Cursor; + + // Given network config content to simulate stdin input + let test_content = r"version = 2 + +[eth0] +dhcp4 = true +primary = true +"; + let mock_stdin = tokio::io::BufReader::new(Cursor::new(test_content.as_bytes())); + + // When reading from mock stdin + let result = get_content_with_stdin(mock_stdin).await; + + // Then content should be read successfully + assert!(result.is_ok()); + assert_eq!(result.unwrap(), test_content); + } + + #[tokio::test] + async fn test_get_content_stdin_empty() { + use std::io::Cursor; + + // Given empty stdin + let mock_stdin = tokio::io::BufReader::new(Cursor::new(b"")); + + // When reading from empty mock stdin + let result = get_content_with_stdin(mock_stdin).await; + + // Then should return empty string successfully + assert!(result.is_ok()); + assert_eq!(result.unwrap(), ""); + } + + #[tokio::test] + async fn test_stdin_vs_uri_behavior() { + use std::io::Cursor; + + let content = r"version = 2 + +[eth0] +dhcp4 = true"; + let mock_stdin = tokio::io::BufReader::new(Cursor::new(content.as_bytes())); + + // Test that stdin reader works + let stdin_result = get_content_with_stdin(mock_stdin).await; + + // Test that Some input uses URI processing + let uri_result = + get_content(Some("base64:dmVyc2lvbiA9IDIKCltldGgwXQpkaGNwNCA9IHRydWU=")).await; + + assert!(stdin_result.is_ok()); + assert!(uri_result.is_ok()); + assert_eq!(stdin_result.unwrap(), content); + assert_eq!(uri_result.unwrap(), content); + } + + #[tokio::test] + async fn test_get_content_base64() { + // Given a valid TOML network configuration encoded in base64 + let test_content = "version = 2\n\n[eth0]\ndhcp4 = true\n"; + let encoded = engine::general_purpose::STANDARD.encode(test_content.as_bytes()); + let base64_uri = format!("base64:{}", encoded); + + // Then get content from the base64 URI + let result = get_content(Some(base64_uri)).await; + + // Then the content should be successfully decoded + assert!(result.is_ok()); + assert_eq!(result.unwrap(), test_content); + } + + #[tokio::test] + async fn test_get_content_base64_invalid() { + // Given an invalid base64 string that cannot be decoded + // When attempting to get content from the malformed base64 URI + let result = get_content(Some("base64:invalid!@#$")).await; + + // Then the operation should fail with a base64 decode error + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to decode base64")); + } + + #[tokio::test] + async fn test_get_content_base64_invalid_utf8() { + // Given a valid base64 string that decodes to invalid UTF-8 bytes + let invalid_bytes = vec![0xFF, 0xFE, 0xFD]; + let encoded = engine::general_purpose::STANDARD.encode(&invalid_bytes); + let base64_uri = format!("base64:{}", encoded); + + // When attempting to get content from the base64 URI + let result = get_content(Some(base64_uri)).await; + + // Then the operation should fail with a UTF-8 validation error + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not valid UTF-8")); + } + + #[tokio::test] + async fn test_uri_parsing() { + // Given an invalid URI string that cannot be parsed + // When attempting to get content from the malformed URI + let result = get_content(Some("invalid-uri")).await; + + // Then the operation should fail with a URI parsing error + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid URI")); + } + + #[test_case("http://example.com/net.toml", "http"; "http uri rejected")] + #[test_case("https://example.com/net.toml", "https"; "https uri rejected")] + #[test_case("s3://bucket/net.toml", "s3"; "s3 uri rejected")] + #[test_case("ftp://ftp.example.com/net.toml", "ftp"; "ftp uri rejected")] + #[test_case("data:text/plain;charset=utf-8,version=2", "data"; "data uri rejected")] + #[tokio::test] + async fn test_unsupported_uri_schemes_rejected(uri: &str, expected_scheme: &str) { + // When attempting to get content from an unsupported URI scheme + let result = get_content(Some(uri)).await; + + // Then the operation should fail with an unsupported URI scheme error + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains(&format!("Unsupported URI scheme '{expected_scheme}'"))); + assert!(error_msg.contains("Only file:// and base64: schemes are supported")); + } +} diff --git a/sources/api/apiserver/src/server/error.rs b/sources/api/apiserver/src/server/error.rs index 0e5f5c4ee..eb39d7a0a 100644 --- a/sources/api/apiserver/src/server/error.rs +++ b/sources/api/apiserver/src/server/error.rs @@ -220,6 +220,18 @@ pub enum Error { // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + // Network configuration errors + #[snafu(display("Network configuration content is not valid UTF-8"))] + NetworkConfigContent { source: std::string::FromUtf8Error }, + + #[snafu(display("Failed to validate network configuration: {}", source))] + NetworkConfigValidation { source: std::io::Error }, + + #[snafu(display("Invalid network configuration: {}", stderr))] + NetworkConfigInvalid { stderr: String }, + + // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + // Update related errors #[snafu(display("Unable to start the update dispatcher: {} ", source))] UpdateDispatcher { source: io::Error }, diff --git a/sources/api/apiserver/src/server/mod.rs b/sources/api/apiserver/src/server/mod.rs index c8ba497d7..65d238edd 100644 --- a/sources/api/apiserver/src/server/mod.rs +++ b/sources/api/apiserver/src/server/mod.rs @@ -14,7 +14,7 @@ use actix_web::{ use datastore::{serialize_scalar, Committed, FilesystemDataStore, Key, KeyType, Value}; use error::Result; use http::StatusCode; -use log::info; +use log::{debug, info}; use model::ephemeral_storage::{Bind, Init}; use model::generator::{RawSettingsGenerator, Strength}; use model::{ConfigurationFiles, Model, Report, Services, Settings}; @@ -24,6 +24,7 @@ use snafu::{ensure, OptionExt, ResultExt}; use std::collections::{HashMap, HashSet}; use std::env; use std::fs::{set_permissions, File, Permissions}; +use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; @@ -36,7 +37,7 @@ use tokio::process::Command as AsyncCommand; const BLOODHOUND_BIN: &str = "/usr/bin/bloodhound"; const BLOODHOUND_K8S_CHECKS: &str = "/usr/libexec/cis-checks/kubernetes"; const BLOODHOUND_FIPS_CHECKS: &str = "/usr/libexec/fips-checks/bottlerocket"; - +const NETDOG_BIN: &str = "/usr/bin/netdog"; // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= // sd_notify helper @@ -137,6 +138,7 @@ where .route("/prepare-update", web::post().to(prepare_update)) .route("/activate-update", web::post().to(activate_update)) .route("/deactivate-update", web::post().to(deactivate_update)) + .route("/network/configure", web::post().to(configure_network)) .route( "/ephemeral-storage/init", web::post().to(initialize_ephemeral_storage), @@ -687,6 +689,43 @@ async fn reboot() -> Result { Ok(HttpResponse::NoContent().finish()) } +/// Configures network settings by invoking netdog commit. +/// The configuration will be applied at next boot - a reboot is required for changes to take effect. +async fn configure_network(body: web::Bytes) -> Result { + debug!("Configuring network settings"); + + // Convert the body bytes to a UTF-8 string + let content = String::from_utf8(body.to_vec()).context(error::NetworkConfigContentSnafu)?; + + info!("Invoking netdog commit to validate and write network configuration"); + + // Validate and write net.toml using netdog commit + let validation_result = std::process::Command::new(NETDOG_BIN) + .arg("commit") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(content.as_bytes())?; + } + child.wait_with_output() + }) + .context(error::NetworkConfigValidationSnafu)?; + + if !validation_result.status.success() { + let stderr = String::from_utf8_lossy(&validation_result.stderr); + error!( + "netdog commit failed: network configuration validation failed: {}", + stderr + ); + return error::NetworkConfigInvalidSnafu { stderr }.fail(); + } + + Ok(HttpResponse::NoContent().finish()) +} + /// Gets the set of report types supported by this host. async fn list_reports() -> Result { // Add each report to list response when adding a new handler @@ -965,6 +1004,9 @@ impl ResponseError for error::Error { InvalidPrefix { .. } => StatusCode::BAD_REQUEST, DeserializeJson { .. } => StatusCode::BAD_REQUEST, InvalidKeyPair { .. } => StatusCode::BAD_REQUEST, + NetworkConfigContent { .. } => StatusCode::BAD_REQUEST, + NetworkConfigValidation { .. } => StatusCode::BAD_REQUEST, + NetworkConfigInvalid { .. } => StatusCode::BAD_REQUEST, // 404 Not Found MissingData { .. } => StatusCode::NOT_FOUND, diff --git a/sources/api/openapi.yaml b/sources/api/openapi.yaml index f46bbd47a..3cd3bca6d 100644 --- a/sources/api/openapi.yaml +++ b/sources/api/openapi.yaml @@ -722,6 +722,30 @@ paths: 423: description: "Update write lock held. Try again in a moment" + /actions/network/configure: + post: + summary: "Configure network settings from net.toml content" + operationId: "configure_network" + requestBody: + required: true + content: + text/plain: + schema: + type: string + example: | + version = 2 + + [eth0] + dhcp4 = true + primary = true + responses: + 204: + description: "Network configuration successfully written" + 400: + description: "Invalid UTF-8 content or malformed configuration" + 500: + description: "Server error writing configuration" + /updates/status: get: summary: "Get update status" diff --git a/sources/models/src/ephemeral_storage.rs b/sources/models/src/ephemeral_storage.rs index 3c76a7596..a33bc482f 100644 --- a/sources/models/src/ephemeral_storage.rs +++ b/sources/models/src/ephemeral_storage.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; /// Supported filesystems for ephemeral storage -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Filesystem { Xfs, Ext4, @@ -19,7 +19,7 @@ impl Display for Filesystem { } /// Storage type preferences for ephemeral storage -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Preference { pub ephemeral_disk: bool, pub ebs_volume: bool, diff --git a/sources/netdog/Cargo.toml b/sources/netdog/Cargo.toml index 6664555a0..3621eb60d 100644 --- a/sources/netdog/Cargo.toml +++ b/sources/netdog/Cargo.toml @@ -25,6 +25,7 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true serde_plain.workspace = true snafu.workspace = true +tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } toml = { workspace = true, features = ["preserve_order"] } diff --git a/sources/netdog/src/cli/commit.rs b/sources/netdog/src/cli/commit.rs new file mode 100644 index 000000000..651ec8dec --- /dev/null +++ b/sources/netdog/src/cli/commit.rs @@ -0,0 +1,72 @@ +use crate::cli::{error, Result}; +use argh::FromArgs; +use snafu::ResultExt; +use std::io::Read; +use std::path::Path; +use tempfile::NamedTempFile; + +/// Validate and commit network configuration from stdin +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "commit")] +pub struct CommitArgs {} + +pub fn run(_args: CommitArgs) -> Result<()> { + let mut content = String::new(); + std::io::stdin() + .read_to_string(&mut content) + .context(error::StdinReadSnafu)?; + + crate::net_config::deserialize_config(&content).context(error::NetConfigStdinParseSnafu)?; + + let config_dir = Path::new("/.bottlerocket"); + let config_file = config_dir.join("net.toml"); + + if !config_dir.exists() { + return error::NetConfigDirMissingSnafu { path: config_dir }.fail(); + } + + let tempfile = NamedTempFile::new_in(config_dir).context(error::CreateTempFileSnafu { + path: config_dir.to_path_buf(), + })?; + + std::fs::write(tempfile.path(), &content).context(error::WriteTempFileSnafu)?; + + // Ensure configuration changes are atomic to prevent partial writes + tempfile + .persist(&config_file) + .context(error::PersistTempFileSnafu { path: config_file })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_commit_invalid_version() { + let invalid_config = "version = 99\n\n[eth0]\ndhcp4 = true"; + let mut stdin = Cursor::new(invalid_config.as_bytes()); + + let mut content = String::new(); + stdin.read_to_string(&mut content).unwrap(); + + let result = crate::net_config::deserialize_config(&content); + + assert!(result.is_err()); + } + + #[test] + fn test_commit_valid_config() { + let valid_config = "version = 3\n\n[enp0s16]\ndhcp4 = true\ndhcp6 = false\nprimary = true\n\n[enp0s17]\ndhcp4 = true\ndhcp6 = false"; + let mut stdin = Cursor::new(valid_config.as_bytes()); + + let mut content = String::new(); + stdin.read_to_string(&mut content).unwrap(); + + let result = crate::net_config::deserialize_config(&content); + + assert!(result.is_ok()); + } +} diff --git a/sources/netdog/src/cli/mod.rs b/sources/netdog/src/cli/mod.rs index fdaf95d44..a5d55a490 100644 --- a/sources/netdog/src/cli/mod.rs +++ b/sources/netdog/src/cli/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod commit; pub(crate) mod generate_hostname; pub(crate) mod generate_net_config; pub(crate) mod node_ip; @@ -10,6 +11,7 @@ use crate::{ DEFAULT_NET_CONFIG_FILE, KERNEL_CMDLINE, PRIMARY_INTERFACE, PRIMARY_MAC_ADDRESS, PRIMARY_SYSCTL_CONF, SYSCTL_MARKER_FILE, SYSTEMD_SYSCTL, SYS_CLASS_NET, USR_NET_CONFIG_FILE, }; +pub(crate) use commit::CommitArgs; pub(crate) use generate_hostname::GenerateHostnameArgs; pub(crate) use generate_net_config::GenerateNetConfigArgs; pub(crate) use node_ip::NodeIpArgs; @@ -319,6 +321,30 @@ mod error { link: PathBuf, source: io::Error, }, + + #[snafu(display("Failed to read from stdin: {}", source))] + StdinRead { source: std::io::Error }, + + #[snafu(display("Failed to parse network config from stdin: {}", source))] + NetConfigStdinParse { source: net_config::Error }, + + #[snafu(display("Network config directory '{}' does not exist", path.display()))] + NetConfigDirMissing { path: PathBuf }, + + #[snafu(display("Failed to create temp file in '{}': {}", path.display(), source))] + CreateTempFile { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("Failed to write to temp file: {}", source))] + WriteTempFile { source: std::io::Error }, + + #[snafu(display("Failed to persist temp file to '{}': {}", path.display(), source))] + PersistTempFile { + path: PathBuf, + source: tempfile::PersistError, + }, } } diff --git a/sources/netdog/src/main.rs b/sources/netdog/src/main.rs index 7f559896a..7d2567be9 100644 --- a/sources/netdog/src/main.rs +++ b/sources/netdog/src/main.rs @@ -70,6 +70,7 @@ struct Args { #[derive(FromArgs, PartialEq, Debug)] #[argh(subcommand)] enum SubCommand { + Commit(cli::CommitArgs), NodeIp(cli::NodeIpArgs), GenerateHostname(cli::GenerateHostnameArgs), GenerateNetConfig(cli::GenerateNetConfigArgs), @@ -81,6 +82,7 @@ enum SubCommand { async fn run() -> cli::Result<()> { let args: Args = argh::from_env(); match args.subcommand { + SubCommand::Commit(args) => cli::commit::run(args)?, SubCommand::NodeIp(_) => cli::node_ip::run()?, SubCommand::GenerateHostname(_) => cli::generate_hostname::run().await?, SubCommand::GenerateNetConfig(_) => cli::generate_net_config::run()?, diff --git a/sources/netdog/src/net_config/mod.rs b/sources/netdog/src/net_config/mod.rs index dca831a24..358ba5d9d 100644 --- a/sources/netdog/src/net_config/mod.rs +++ b/sources/netdog/src/net_config/mod.rs @@ -103,7 +103,7 @@ where /// Deserialize the network config, using the version key to determine which config struct to /// deserialize into -fn deserialize_config(config_str: &str) -> Result> { +pub fn deserialize_config(config_str: &str) -> Result> { #[derive(Debug, Deserialize)] struct ConfigToml { version: u8,