Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions sources/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions sources/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,11 @@ git = "https://github.com/bottlerocket-os/bottlerocket-settings-sdk"
tag = "bottlerocket-settings-plugin-v0.1.0"
version = "0.1.0"

[workspace.dependencies.settings-extension-measurement]
git = "https://github.com/bottlerocket-os/bottlerocket-settings-sdk"
tag = "bottlerocket-settings-models-v0.27.0"
version = "0.1.0"

[workspace.dependencies.settings-extension-oci-defaults]
git = "https://github.com/bottlerocket-os/bottlerocket-settings-sdk"
tag = "bottlerocket-settings-models-v0.27.0"
Expand Down
3 changes: 3 additions & 0 deletions sources/rottweiler/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ hkdf = { workspace = true, features = ["std"] }
imdsclient.workspace = true
nix = { workspace = true, features = ["fs", "ioctl", "mount"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
settings-extension-measurement.workspace = true
sha2.workspace = true
simplelog.workspace = true
snafu.workspace = true
tokio = { workspace = true, features = ["macros", "rt"] }
walkdir.workspace = true
Expand Down
6 changes: 5 additions & 1 deletion sources/rottweiler/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ For convenience, the following aliases are supported:
*/

use argh::FromArgs;
use snafu::Whatever;
use simplelog::{Config as LogConfig, LevelFilter, SimpleLogger};
use snafu::{ResultExt, Whatever};
use std::path::{Path, PathBuf};

mod block_device;
Expand All @@ -59,6 +60,9 @@ type Result<T> = std::result::Result<T, Whatever>;
#[tokio::main(flavor = "current_thread")]
#[snafu::report]
async fn main() -> Result<()> {
SimpleLogger::init(LevelFilter::Info, LogConfig::default())
.whatever_context("failed to initialize logger")?;

// Support aliases: "dir" -> "directory", "bdev" -> "block-device"
// Only replace in resource-type subcommand positions
let mut args: Vec<String> = std::env::args().collect();
Expand Down
150 changes: 137 additions & 13 deletions sources/rottweiler/src/system.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use serde::Deserialize;
use settings_extension_measurement::MeasurementSettingsV1;
use snafu::FromString;
use snafu::prelude::*;
use std::io::Write;
Expand Down Expand Up @@ -216,21 +218,77 @@ pub(crate) fn ephemeral_encryption_keys_enabled() -> Result<bool> {
Ok(features.ephemeral_encryption_keys)
}

/// Get canonicalized settings from apiclient, excluding seed and hostname
pub fn apiclient_get_settings() -> Result<Zeroizing<Vec<u8>>> {
execute(
/// Wrapper structs for deserializing the apiclient verbose JSON response.
/// apiclient returns: `{"settings":{"measurement":{"excluded-settings":[...]}}}`
#[derive(Deserialize)]
struct ApiSettingsResponse {
settings: ApiSettingsMeasurement,
}

#[derive(Deserialize)]
struct ApiSettingsMeasurement {
measurement: MeasurementSettingsV1,
}

/// Get the user-configured excluded settings from the API.
/// Returns an empty list if the setting is not configured.
fn get_extra_excluded_settings() -> Result<Vec<String>> {
let output = execute(
APICLIENT,
&[
"get",
"settings",
"--exclude",
"settings.updates.seed",
"--exclude",
"settings.network.hostname",
"--canonicalize",
],
&["get", "settings.measurement.excluded-settings"],
None,
)
)?;

let text = String::from_utf8_lossy(&output);
let response: ApiSettingsResponse =
serde_json::from_str(text.trim()).with_whatever_context(|e| {
format!("failed to parse measurement exclusion settings: {e}")
})?;

let excluded = match response.settings.measurement.excluded_settings {
Some(values) => values,
None => return Ok(Vec::new()),
};

// Normalize values by stripping a leading "settings." prefix if present.
// Users may provide either "host-containers" or "settings.host-containers";
// we need bare names for constructing the apiclient `--exclude` arguments.
Ok(excluded
.iter()
.map(|v| {
v.trim()
.strip_prefix("settings.")
.unwrap_or(v.trim())
.to_string()
})
.collect())
}

/// Get canonicalized settings from apiclient, excluding seed, hostname, and
/// any additional settings the customer has configured via
/// `settings.measurement.excluded-settings`.
pub fn apiclient_get_settings() -> Result<Zeroizing<Vec<u8>>> {
let extra_exclusions = get_extra_excluded_settings()?;

let mut args: Vec<String> = vec![
"get".into(),
"settings".into(),
"--exclude".into(),
"settings.updates.seed".into(),
"--exclude".into(),
"settings.network.hostname".into(),
];

args.extend(
extra_exclusions
.iter()
.flat_map(|s| ["--exclude".to_string(), format!("settings.{}", s)]),
);

args.push("--canonicalize".into());

let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
execute(APICLIENT, &arg_refs, None)
}

/// Extend a TPM PCR with SHA256, SHA384, and SHA512 hashes
Expand Down Expand Up @@ -272,6 +330,8 @@ fn describe_imds_userdata_error(e: imdsclient::Error) -> snafu::Whatever {
mod tests {
use super::*;

// === IMDS user data error sanitization ===

/// A response body standing in for secret-bearing user data, used only to assert it never
/// appears in the sanitized error message.
const SECRET_USER_DATA: &str = "super-secret-bootstrap-token=abc123";
Expand Down Expand Up @@ -304,4 +364,68 @@ mod tests {
assert!(!message.contains(SECRET_USER_DATA));
assert_eq!(message, "failed to fetch user data from IMDS");
}

// === API response deserialization ===

#[test]
fn parse_nested_json_response() {
// Simulate what apiclient actually returns
let json = r#"{"settings":{"measurement":{"excluded-settings":["host-containers","bootstrap-containers"]}}}"#;
let response: ApiSettingsResponse = serde_json::from_str(json).unwrap();
let excluded = response.settings.measurement.excluded_settings.unwrap();
assert_eq!(excluded, vec!["host-containers", "bootstrap-containers"]);
}

#[test]
fn parse_empty_excluded_settings() {
let json = r#"{"settings":{"measurement":{"excluded-settings":[]}}}"#;
let response: ApiSettingsResponse = serde_json::from_str(json).unwrap();
let excluded = response.settings.measurement.excluded_settings.unwrap();
assert!(excluded.is_empty());
}

#[test]
fn parse_missing_excluded_settings_field() {
// When the excluded-settings key is absent, it deserializes as None
let json = r#"{"settings":{"measurement":{}}}"#;
let response: ApiSettingsResponse = serde_json::from_str(json).unwrap();
assert!(response.settings.measurement.excluded_settings.is_none());
}

#[test]
fn parse_malformed_json_fails() {
let json = r#"not json at all"#;
let result = serde_json::from_str::<ApiSettingsResponse>(json);
assert!(result.is_err());
}

#[test]
fn parse_wrong_structure_fails() {
// Missing "settings" wrapper
let json = r#"{"measurement":{"excluded-settings":["host-containers"]}}"#;
let result = serde_json::from_str::<ApiSettingsResponse>(json);
assert!(result.is_err());
}

// === Normalization for apiclient args ===

#[test]
fn normalize_strips_settings_prefix() {
let value = "settings.host-containers";
let normalized = value
.trim()
.strip_prefix("settings.")
.unwrap_or(value.trim());
assert_eq!(normalized, "host-containers");
}

#[test]
fn normalize_leaves_bare_value_unchanged() {
let value = "host-containers";
let normalized = value
.trim()
.strip_prefix("settings.")
.unwrap_or(value.trim());
assert_eq!(normalized, "host-containers");
}
}
Loading