diff --git a/Cargo.lock b/Cargo.lock index 5fc4b210..08da86bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,7 @@ dependencies = [ "cfg-if", "clap", "ipnetwork", + "libc", "libflate", "libsystemd", "mailparse", @@ -40,6 +41,7 @@ dependencies = [ "nix 0.30.1", "openssh-keys", "openssl", + "percent-encoding", "pnet_base", "pnet_datalink", "reqwest", @@ -54,6 +56,7 @@ dependencies = [ "tempfile", "uzers", "vmw_backdoor", + "xml-rs", "zbus", ] diff --git a/Cargo.toml b/Cargo.toml index fbf6a105..d7712eff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ base64 = "0.22" cfg-if = "1.0" clap = { version = "4", "default-features" = false, "features" = ["std", "cargo", "derive", "error-context", "help", "suggestions", "usage", "wrap_help"] } ipnetwork = ">= 0.17, < 0.22" +libc = "0.2" libflate = "2.1" libsystemd = ">= 0.2.1, < 0.8.0" mailparse = ">= 0.13, < 0.17" @@ -46,6 +47,7 @@ maplit = "1.0" nix = { version = ">= 0.19, < 0.31", "default-features" = false, "features" = [ "mount", "user"] } openssh-keys = ">= 0.5, < 0.7" openssl = ">= 0.10.46, < 0.11" +percent-encoding = "2.3.2" pnet_base = ">= 0.26, < 0.36" pnet_datalink = ">= 0.26, < 0.36" reqwest = { version = ">= 0.10, < 0.13", features = [ "blocking" ] } @@ -54,6 +56,7 @@ serde-xml-rs = ">= 0.4, < 0.9" serde_json = "1.0" serde_yaml = ">= 0.8, < 0.10" slog = { version = "2.7", features = ["max_level_trace", "release_max_level_info"] } +xml-rs = "0.8" slog-async = ">= 2.5, < 3" slog-scope = "4.3" slog-term = ">= 2.6, < 3" diff --git a/docs/release-notes.md b/docs/release-notes.md index c09c7565..b0be1d7e 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,6 +9,7 @@ nav_order: 8 Major changes: - KubeVirt: Add support for static and dynamic IP configuration from cloud-init +- Azure: Add `render-ignition` subcommand to generate Ignition config fragments from IMDS metadata - Hetzner: Add support for network configuration Minor changes: diff --git a/dracut/30afterburn/afterburn-ignition-fragment.service b/dracut/30afterburn/afterburn-ignition-fragment.service new file mode 100644 index 00000000..a850b803 --- /dev/null +++ b/dracut/30afterburn/afterburn-ignition-fragment.service @@ -0,0 +1,26 @@ +[Unit] +Description=Afterburn Ignition Config Fragment Generator +Documentation=https://coreos.github.io/afterburn/ + +# Only run on platforms that support ignition fragment generation. +ConditionKernelCommandLine=|ignition.platform.id=azure + +# Run after networking is available (needed for the Azure IMDS call). Use +# network.target (not network-online.target) to match ignition-fetch.service +# and avoid stalling on multi-NIC DHCP; IMDS is link-local and afterburn +# has its own retry logic. +# +# Run between fetch and disks so writes don't race the fetch stage's parse +# of base.platform.d (Ignition re-reads it every stage; applied at files). +After=network.target +After=ignition-fetch.service +Before=ignition-disks.service + +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +ExecStartPre=/usr/bin/mkdir -p /etc/ignition/base.platform.d/azure +ExecStart=/usr/bin/afterburn render-ignition --cmdline --render-ignition-dir /etc/ignition/base.platform.d/azure/ +Type=oneshot +RemainAfterExit=yes diff --git a/dracut/30afterburn/module-setup.sh b/dracut/30afterburn/module-setup.sh index 171fb634..f9cfb5fd 100755 --- a/dracut/30afterburn/module-setup.sh +++ b/dracut/30afterburn/module-setup.sh @@ -23,9 +23,13 @@ install() { inst_simple "$moddir/afterburn-network-kargs.service" \ "$systemdutildir/system/afterburn-network-kargs.service" + inst_simple "$moddir/afterburn-ignition-fragment.service" \ + "$systemdutildir/system/afterburn-ignition-fragment.service" + # These services are only run once on first-boot, so they piggyback # on Ignition completion target. mkdir -p "$initdir/$systemdsystemunitdir/ignition-complete.target.requires" ln -s "../afterburn-hostname.service" "$initdir/$systemdsystemunitdir/ignition-complete.target.requires/afterburn-hostname.service" ln -s "../afterburn-network-kargs.service" "$initdir/$systemdsystemunitdir/ignition-complete.target.requires/afterburn-network-kargs.service" + ln -s "../afterburn-ignition-fragment.service" "$initdir/$systemdsystemunitdir/ignition-complete.target.requires/afterburn-ignition-fragment.service" } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4055187d..312db2ea 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ use slog_scope::trace; mod exp; mod multi; +mod render_ignition; /// Path to kernel command-line (requires procfs mount). const CMDLINE_PATH: &str = "/proc/cmdline"; @@ -19,6 +20,7 @@ pub(crate) enum CliConfig { Multi(multi::CliMulti), #[clap(subcommand)] Exp(exp::CliExp), + RenderIgnition(render_ignition::CliRenderIgnition), } impl CliConfig { @@ -27,6 +29,7 @@ impl CliConfig { match self { CliConfig::Multi(cmd) => cmd.run(), CliConfig::Exp(cmd) => cmd.run(), + CliConfig::RenderIgnition(cmd) => cmd.run(), } } } @@ -231,4 +234,57 @@ mod tests { parse_args(t3).unwrap(); } + + #[test] + fn test_render_ignition_cmd() { + let args: Vec<_> = [ + "afterburn", + "render-ignition", + "--provider", + "azure", + "--render-ignition-dir", + "/tmp/fragments", + "--disable-hostname-fragment", + "--disable-user-fragment", + ] + .iter() + .map(ToString::to_string) + .collect(); + + let cmd = parse_args(args).unwrap(); + match cmd { + CliConfig::RenderIgnition(_) => {} + x => panic!("unexpected cmd: {x:?}"), + }; + } + + #[test] + fn test_render_ignition_defaults() { + let args: Vec<_> = [ + "afterburn", + "render-ignition", + "--cmdline", + "--render-ignition-dir", + "/tmp/fragments", + ] + .iter() + .map(ToString::to_string) + .collect(); + + let cmd = parse_args(args).unwrap(); + match cmd { + CliConfig::RenderIgnition(_) => {} + x => panic!("unexpected cmd: {x:?}"), + }; + } + + #[test] + fn test_render_ignition_requires_render_ignition_dir() { + let args: Vec<_> = ["afterburn", "render-ignition", "--provider", "azure"] + .iter() + .map(ToString::to_string) + .collect(); + + parse_args(args).unwrap_err(); + } } diff --git a/src/cli/render_ignition.rs b/src/cli/render_ignition.rs new file mode 100644 index 00000000..668b7503 --- /dev/null +++ b/src/cli/render_ignition.rs @@ -0,0 +1,239 @@ +//! `render-ignition` CLI sub-command. +//! +//! Fetches metadata from a cloud provider and writes Ignition config fragment +//! files for the enabled features. The fragment types and their serialization +//! live in [`crate::ignition`]; this module turns provider metadata into those +//! fragments and handles argument parsing and dispatch. + +use anyhow::{bail, Context, Result}; +use clap::{ArgGroup, Parser}; +use slog_scope::{info, warn}; +use std::path::Path; + +use crate::ignition::IgnitionConfig; +use crate::providers::MetadataProvider; + +/// Render Ignition config fragments from cloud provider metadata +#[derive(Debug, Parser)] +#[command(group(ArgGroup::new("provider-group").args(["cmdline", "provider"]).required(true)))] +pub struct CliRenderIgnition { + /// The name of the cloud provider + #[arg(long, value_name = "name")] + provider: Option, + /// Read the cloud provider from the kernel cmdline + #[arg(long)] + cmdline: bool, + /// Directory to write Ignition config fragment files into + #[arg(long = "render-ignition-dir", value_name = "path")] + render_ignition_dir: String, + /// Do not write the hostname fragment file + #[arg(long)] + disable_hostname_fragment: bool, + /// Do not write the platform user fragment file + #[arg(long)] + disable_user_fragment: bool, +} + +impl CliRenderIgnition { + const SUPPORTED_PROVIDERS: &[&str] = &["azure"]; + + pub(crate) fn run(self) -> Result<()> { + let provider_id = super::get_provider(self.provider.as_deref())?; + + if !Self::SUPPORTED_PROVIDERS.contains(&provider_id.as_str()) { + bail!( + "render-ignition is only supported for providers {:?}, got '{}'", + Self::SUPPORTED_PROVIDERS, + provider_id, + ); + } + + let hostname = !self.disable_hostname_fragment; + let platform_user = !self.disable_user_fragment; + + if !hostname && !platform_user { + slog_scope::warn!("render-ignition: all fragments disabled, nothing to do"); + return Ok(()); + } + + let metadata = crate::metadata::fetch_metadata(&provider_id) + .context("fetching metadata from provider")?; + + if hostname { + generate_hostname_fragment(metadata.as_ref(), &self.render_ignition_dir) + .context("generating hostname ignition fragment")?; + } + + if platform_user { + generate_user_fragment(metadata.as_ref(), &self.render_ignition_dir) + .context("generating platform-user ignition fragment")?; + } + + Ok(()) + } +} + +fn generate_hostname_fragment(provider: &dyn MetadataProvider, output_dir: &str) -> Result<()> { + let hostname = match provider.hostname()? { + Some(h) => h, + None => { + warn!("hostname requested, but not available from this provider"); + return Ok(()); + } + }; + + let path = Path::new(output_dir).join("hostname.ign"); + IgnitionConfig::hostname_fragment(&hostname).write_to(&path)?; + info!("wrote hostname ignition fragment"; "path" => path.display().to_string()); + Ok(()) +} + +fn generate_user_fragment(provider: &dyn MetadataProvider, output_dir: &str) -> Result<()> { + let username = provider + .admin_username() + .context("failed to query admin username from provider")?; + let username = match username { + Some(u) => u, + None => { + warn!("platform-user requested, but admin username not available from this provider"); + return Ok(()); + } + }; + + let ssh_keys: Vec = provider + .ssh_keys() + .context("failed to query SSH keys from provider")? + .into_iter() + .map(|k| k.to_key_format()) + .collect(); + + let password_hash = provider + .admin_password_hash() + .context("failed to query admin password hash from provider")?; + + let path = Path::new(output_dir).join("user.ign"); + IgnitionConfig::user_fragment(username, ssh_keys, password_hash).write_to(&path)?; + info!("wrote platform-user ignition fragment"; "path" => path.display().to_string()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openssh_keys::PublicKey; + use std::fs; + + /// Minimal generic provider used to exercise the fragment generators + /// without any platform-specific plumbing. + struct FakeProvider { + hostname: Option, + admin_username: Option, + ssh_keys: Vec<&'static str>, + admin_password_hash: Option, + } + + impl MetadataProvider for FakeProvider { + fn hostname(&self) -> Result> { + Ok(self.hostname.clone()) + } + fn admin_username(&self) -> Result> { + Ok(self.admin_username.clone()) + } + fn ssh_keys(&self) -> Result> { + self.ssh_keys + .iter() + .map(|s| { + s.parse::() + .map_err(|e| anyhow::anyhow!("failed to parse test ssh key: {e}")) + }) + .collect() + } + fn admin_password_hash(&self) -> Result> { + Ok(self.admin_password_hash.clone()) + } + } + + #[test] + fn test_generate_hostname_fragment_writes_file() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let provider = FakeProvider { + hostname: Some("myhost".into()), + admin_username: None, + ssh_keys: vec![], + admin_password_hash: None, + }; + + generate_hostname_fragment(&provider, dir).unwrap(); + + let raw = fs::read_to_string(tmp.path().join("hostname.ign")).unwrap(); + let json: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(json["storage"]["files"][0]["path"], "/etc/hostname"); + assert_eq!( + json["storage"]["files"][0]["contents"]["source"], + "data:,myhost" + ); + } + + #[test] + fn test_generate_hostname_fragment_skipped_when_absent() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let provider = FakeProvider { + hostname: None, + admin_username: None, + ssh_keys: vec![], + admin_password_hash: None, + }; + + generate_hostname_fragment(&provider, dir).unwrap(); + assert!(!tmp.path().join("hostname.ign").exists()); + } + + #[test] + fn test_generate_user_fragment_includes_password_hash() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let provider = FakeProvider { + hostname: None, + admin_username: Some("core".into()), + ssh_keys: vec![ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDYVEprvtYJXVOBN0XNKVVRNCRX6BlnNbI+USLGais1sUWPwtSg7z9K9vhbYAPUZcq8c/s5S9dg5vTHbsiyPCIDOKyeHba4MUJq8Oh5b2i71/3BISpyxTBH/uZDHdslW2a+SrPDCeuMMoss9NFhBdKtDkdG9zyi0ibmCP6yMdEX8Q== test", + ], + admin_password_hash: Some("$6$rounds=10000$salt$hash".into()), + }; + + generate_user_fragment(&provider, dir).unwrap(); + + let raw = fs::read_to_string(tmp.path().join("user.ign")).unwrap(); + let json: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(json["passwd"]["users"][0]["name"], "core"); + assert_eq!( + json["passwd"]["users"][0]["passwordHash"], + "$6$rounds=10000$salt$hash" + ); + assert!(json["passwd"]["users"][0]["sshAuthorizedKeys"][0] + .as_str() + .unwrap() + .starts_with("ssh-rsa ")); + } + + #[test] + fn test_generate_user_fragment_skipped_without_username() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let provider = FakeProvider { + hostname: None, + admin_username: None, + ssh_keys: vec![], + admin_password_hash: Some("$6$rounds=10000$salt$hash".into()), + }; + + generate_user_fragment(&provider, dir).unwrap(); + assert!(!tmp.path().join("user.ign").exists()); + } +} diff --git a/src/ignition.rs b/src/ignition.rs new file mode 100644 index 00000000..ad2fcb14 --- /dev/null +++ b/src/ignition.rs @@ -0,0 +1,334 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Ignition config fragment types. +//! +//! Provider-agnostic representation of a per-feature Ignition config fragment, +//! with helpers to build the hostname/platform-user fragments and write them +//! out as `.ign` files. Ignition merges these natively from +//! `base.platform.d//` under a system config directory such as +//! `/etc/ignition`. Sourcing the underlying data from a provider lives in the +//! `render-ignition` CLI sub-command. + +use anyhow::{Context, Result}; +use serde::Serialize; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +const IGNITION_VERSION: &str = "3.0.0"; + +#[derive(Debug, Serialize)] +pub(crate) struct IgnitionConfig { + pub ignition: IgnitionMeta, + #[serde(skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub passwd: Option, +} + +impl IgnitionConfig { + /// Build a fragment that sets the system hostname via an `/etc/hostname` + /// storage file. + pub(crate) fn hostname_fragment(hostname: &str) -> Self { + IgnitionConfig { + ignition: IgnitionMeta { + version: IGNITION_VERSION.to_string(), + }, + storage: Some(Storage { + files: vec![StorageFile { + path: "/etc/hostname".into(), + mode: 420, + overwrite: true, + contents: FileContents { + source: hostname_data_uri(hostname), + }, + }], + }), + passwd: None, + } + } + + /// Build a fragment that configures a single platform user with the given + /// SSH keys and optional password hash. + pub(crate) fn user_fragment( + name: String, + ssh_keys: Vec, + password_hash: Option, + ) -> Self { + IgnitionConfig { + ignition: IgnitionMeta { + version: IGNITION_VERSION.to_string(), + }, + storage: None, + passwd: Some(Passwd { + users: vec![PasswdUser { + name, + ssh_authorized_keys: if ssh_keys.is_empty() { + None + } else { + Some(ssh_keys) + }, + password_hash, + }], + }), + } + } + + /// Serialize this config and write it as an Ignition fragment file at + /// `path`, creating parent directories and setting mode 0644. + pub(crate) fn write_to(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + let json = + serde_json::to_string_pretty(self).context("failed to serialize ignition config")?; + fs::write(path, json.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o644)) + .with_context(|| format!("failed to set permissions on {}", path.display()))?; + Ok(()) + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct IgnitionMeta { + pub version: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Storage { + pub files: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct StorageFile { + pub path: String, + pub mode: u32, + pub overwrite: bool, + pub contents: FileContents, +} + +#[derive(Debug, Serialize)] +pub(crate) struct FileContents { + pub source: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Passwd { + pub users: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PasswdUser { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_authorized_keys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub password_hash: Option, +} + +fn hostname_data_uri(hostname: &str) -> String { + let encoded = + percent_encoding::utf8_percent_encode(hostname, percent_encoding::NON_ALPHANUMERIC) + .to_string(); + format!("data:,{encoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hostname_fragment_builds_storage_file() { + let cfg = IgnitionConfig::hostname_fragment("myvm"); + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + assert_eq!(v["storage"]["files"][0]["path"], "/etc/hostname"); + assert_eq!(v["storage"]["files"][0]["contents"]["source"], "data:,myvm"); + assert!(v.get("passwd").is_none()); + } + + #[test] + fn test_user_fragment_omits_empty_ssh_keys() { + let cfg = IgnitionConfig::user_fragment("core".into(), vec![], None); + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + assert_eq!(v["passwd"]["users"][0]["name"], "core"); + assert!(v["passwd"]["users"][0].get("sshAuthorizedKeys").is_none()); + assert!(v["passwd"]["users"][0].get("passwordHash").is_none()); + } + + #[test] + fn test_user_fragment_includes_keys_and_hash() { + let cfg = IgnitionConfig::user_fragment( + "core".into(), + vec!["ssh-ed25519 AAAA... test".into()], + Some("$6$rounds=10000$salt$hash".into()), + ); + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + assert_eq!( + v["passwd"]["users"][0]["sshAuthorizedKeys"][0], + "ssh-ed25519 AAAA... test" + ); + assert_eq!( + v["passwd"]["users"][0]["passwordHash"], + "$6$rounds=10000$salt$hash" + ); + } + + #[test] + fn test_ignition_json_with_keys() { + let cfg = IgnitionConfig { + ignition: IgnitionMeta { + version: "3.0.0".into(), + }, + storage: None, + passwd: Some(Passwd { + users: vec![PasswdUser { + name: "testuser".into(), + ssh_authorized_keys: Some(vec!["ssh-ed25519 AAAA...".into()]), + password_hash: None, + }], + }), + }; + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + assert_eq!(v["ignition"]["version"], "3.0.0"); + assert_eq!(v["passwd"]["users"][0]["name"], "testuser"); + assert_eq!( + v["passwd"]["users"][0]["sshAuthorizedKeys"][0], + "ssh-ed25519 AAAA..." + ); + assert!(v["passwd"]["users"][0].get("passwordHash").is_none()); + } + + #[test] + fn test_ignition_json_with_password_hash() { + let cfg = IgnitionConfig { + ignition: IgnitionMeta { + version: "3.0.0".into(), + }, + storage: None, + passwd: Some(Passwd { + users: vec![PasswdUser { + name: "azureuser".into(), + ssh_authorized_keys: None, + password_hash: Some("$6$rounds=10000$salt$hash".into()), + }], + }), + }; + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + assert_eq!(v["passwd"]["users"][0]["name"], "azureuser"); + assert!(v["passwd"]["users"][0].get("sshAuthorizedKeys").is_none()); + assert_eq!( + v["passwd"]["users"][0]["passwordHash"], + "$6$rounds=10000$salt$hash" + ); + } + + #[test] + fn test_write_to_emits_valid_json_and_permissions() { + let tmp = tempfile::tempdir().unwrap(); + let out_file = tmp + .path() + .join("etc/ignition/base.platform.d/azure/extensions.ign"); + + let cfg = IgnitionConfig { + ignition: IgnitionMeta { + version: "3.0.0".into(), + }, + storage: None, + passwd: Some(Passwd { + users: vec![PasswdUser { + name: "core".into(), + ssh_authorized_keys: Some(vec![ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDYVEprvtYJXVOBN0XNKVVRNCRX6BlnNbI+USLGais1sUWPwtSg7z9K9vhbYAPUZcq8c/s5S9dg5vTHbsiyPCIDOKyeHba4MUJq8Oh5b2i71/3BISpyxTBH/uZDHdslW2a+SrPDCeuMMoss9NFhBdKtDkdG9zyi0ibmCP6yMdEX8Q== Generated by Nova".into(), + ]), + password_hash: None, + }], + }), + }; + + cfg.write_to(&out_file).unwrap(); + + assert!(out_file.exists()); + + let raw = fs::read_to_string(&out_file).unwrap(); + let json: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(json["ignition"]["version"], "3.0.0"); + assert_eq!(json["passwd"]["users"][0]["name"], "core"); + + let mode = fs::metadata(&out_file).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o644); + } + + #[test] + fn test_hostname_data_uri() { + assert_eq!(hostname_data_uri("core1"), "data:,core1"); + assert_eq!( + hostname_data_uri("my-vm.internal"), + "data:,my%2Dvm%2Einternal" + ); + } + + #[test] + fn test_hostname_storage_fragment_serialization() { + let cfg = IgnitionConfig { + ignition: IgnitionMeta { + version: "3.0.0".into(), + }, + storage: Some(Storage { + files: vec![StorageFile { + path: "/etc/hostname".into(), + mode: 420, + overwrite: true, + contents: FileContents { + source: hostname_data_uri("myvm"), + }, + }], + }), + passwd: None, + }; + + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + assert_eq!(v["ignition"]["version"], "3.0.0"); + assert_eq!(v["storage"]["files"][0]["path"], "/etc/hostname"); + assert_eq!(v["storage"]["files"][0]["mode"], 420); + assert_eq!(v["storage"]["files"][0]["overwrite"], true); + assert_eq!(v["storage"]["files"][0]["contents"]["source"], "data:,myvm"); + assert!(v.get("passwd").is_none()); + } + + #[test] + fn test_storage_none_omitted_from_json() { + let cfg = IgnitionConfig { + ignition: IgnitionMeta { + version: "3.0.0".into(), + }, + storage: None, + passwd: None, + }; + + let v: serde_json::Value = + serde_json::from_str(&serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + assert!(v.get("storage").is_none()); + assert!(v.get("passwd").is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index f6b2276a..7ff7bd5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ // limitations under the License. mod cli; +mod ignition; mod initrd; mod metadata; mod network; diff --git a/src/providers/microsoft/azure/config.rs b/src/providers/microsoft/azure/config.rs new file mode 100644 index 00000000..ae9aa648 --- /dev/null +++ b/src/providers/microsoft/azure/config.rs @@ -0,0 +1,420 @@ +// Copyright 2026 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Azure OVF admin-password support. +//! +//! Mounts the Azure provisioning ISO, reads `adminPassword` from the OVF +//! environment, and hashes it as an sha512-crypt digest for use in an Ignition +//! `passwd` fragment. The generic Ignition fragment machinery lives in the +//! `render-ignition` CLI sub-command. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use slog_scope::warn; +use std::ffi::{c_char, CStr, CString}; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use xml::reader::{EventReader, XmlEvent as ReadEvent}; +use xml::writer::{EmitterConfig, XmlEvent as WriteEvent}; + +use crate::util; + +const MOUNT_DEVICE: &str = "/dev/sr0"; +const MOUNT_POINT: &str = "/run/afterburn/media/"; +const CDROM_FS_TYPES: &[&str] = &["udf", "iso9660"]; +const MOUNT_RETRIES: u8 = 3; +const PASSWORD_HASH_ROUNDS: usize = 10_000; + +const SALT_ALPHABET: &[u8; 64] = + b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const SALT_LEN: usize = 16; + +// Sizes from libxcrypt's . +const CRYPT_OUTPUT_SIZE: usize = 384; +const CRYPT_MAX_PASSPHRASE_SIZE: usize = 512; +const CRYPT_DATA_RESERVED_SIZE: usize = 767; +const CRYPT_DATA_INTERNAL_SIZE: usize = 30720; + +#[repr(C)] +struct CryptData { + output: [c_char; CRYPT_OUTPUT_SIZE], + setting: [c_char; CRYPT_OUTPUT_SIZE], + input: [c_char; CRYPT_MAX_PASSPHRASE_SIZE], + reserved: [c_char; CRYPT_DATA_RESERVED_SIZE], + initialized: c_char, + internal: [c_char; CRYPT_DATA_INTERNAL_SIZE], +} + +type CryptRFn = unsafe extern "C" fn( + phrase: *const c_char, + setting: *const c_char, + data: *mut CryptData, +) -> *mut c_char; + +/// Resolve libxcrypt's `crypt_r` at runtime so the binary doesn't bake in a +/// specific libcrypt soname; Fedora/RHEL ship `libcrypt.so.2`, Debian/Ubuntu +/// ship `libcrypt.so.1`. +fn load_crypt_r() -> Result { + use std::sync::OnceLock; + static CACHED: OnceLock> = OnceLock::new(); + + match CACHED.get_or_init(|| { + const CANDIDATES: &[&CStr] = &[c"libcrypt.so.2", c"libcrypt.so.1"]; + let mut last_err = "no libcrypt candidates available".to_string(); + for soname in CANDIDATES { + let handle = + unsafe { libc::dlopen(soname.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL) }; + if handle.is_null() { + last_err = format!("dlopen({}) failed", soname.to_string_lossy()); + continue; + } + let sym = unsafe { libc::dlsym(handle, c"crypt_r".as_ptr()) }; + if sym.is_null() { + last_err = format!("{} did not export crypt_r", soname.to_string_lossy()); + continue; + } + let func: CryptRFn = unsafe { std::mem::transmute(sym) }; + return Ok(func); + } + Err(last_err) + }) { + Ok(func) => Ok(*func), + Err(msg) => anyhow::bail!("failed to load crypt_r: {msg}"), + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename = "Environment")] +struct OvfEnvironment { + #[serde(rename = "ProvisioningSection")] + provisioning_section: ProvisioningSection, +} + +#[derive(Debug, Deserialize)] +struct ProvisioningSection { + #[serde(rename = "LinuxProvisioningConfigurationSet")] + linux_prov_conf_set: LinuxProvisioningConfigurationSet, +} + +#[derive(Debug, Deserialize)] +struct LinuxProvisioningConfigurationSet { + #[serde(rename = "AdminPassword", alias = "adminPassword", default)] + admin_password: String, +} + +/// OVF is optional; if present, only `adminPassword` is consulted. +pub(crate) fn read_ovf_admin_password() -> Result> { + let xml = match mount_and_read_ovf() { + Ok(s) => s, + Err(e) => { + warn!("could not read OVF media: {}", e); + return Ok(None); + } + }; + + let env = parse_ovf_env(&xml).context("failed to parse OVF provisioning data")?; + let admin_password = env.provisioning_section.linux_prov_conf_set.admin_password; + + if admin_password.trim().is_empty() { + Ok(None) + } else { + Ok(Some(admin_password)) + } +} + +pub(crate) fn hash_admin_password(password: &str) -> Result { + let salt = generate_salt().context("failed to generate password salt")?; + sha512_crypt(password, &salt, PASSWORD_HASH_ROUNDS) +} + +fn generate_salt() -> Result { + let mut bytes = [0u8; SALT_LEN]; + let mut urandom = fs::File::open("/dev/urandom").context("failed to open /dev/urandom")?; + urandom + .read_exact(&mut bytes) + .context("failed to read random bytes for salt")?; + Ok(bytes + .iter() + .map(|b| SALT_ALPHABET[(*b as usize) % SALT_ALPHABET.len()] as char) + .collect()) +} + +fn sha512_crypt(password: &str, salt: &str, rounds: usize) -> Result { + let setting = CString::new(format!("$6$rounds={rounds}${salt}")) + .context("crypt setting contains a NUL byte")?; + let phrase = CString::new(password).context("password contains a NUL byte")?; + + let crypt_r = load_crypt_r()?; + + // CryptData is ~32 KiB; box it to avoid a large stack frame. + let mut data: Box = unsafe { Box::new(std::mem::zeroed()) }; + + let out_ptr = unsafe { crypt_r(phrase.as_ptr(), setting.as_ptr(), &mut *data) }; + if out_ptr.is_null() { + anyhow::bail!("crypt_r failed: {}", std::io::Error::last_os_error()); + } + let out = unsafe { CStr::from_ptr(out_ptr) } + .to_str() + .context("crypt_r output was not valid UTF-8")?; + // libxcrypt signals failure with a leading '*'. + if out.starts_with('*') { + anyhow::bail!("crypt_r reported a hashing failure: {out}"); + } + Ok(out.to_owned()) +} + +fn mount_and_read_ovf() -> Result { + let device = Path::new(MOUNT_DEVICE); + let mount_point = Path::new(MOUNT_POINT); + + fs::create_dir_all(mount_point)?; + fs::set_permissions(mount_point, fs::Permissions::from_mode(0o700))?; + + let mut mounted = false; + for fstype in CDROM_FS_TYPES { + if util::mount_ro(device, mount_point, fstype, MOUNT_RETRIES).is_ok() { + mounted = true; + break; + } + } + if !mounted { + anyhow::bail!( + "failed to mount {MOUNT_DEVICE} (tried {:?})", + CDROM_FS_TYPES + ); + } + + let result = fs::read_to_string(mount_point.join("ovf-env.xml")); + let _ = util::unmount(mount_point, MOUNT_RETRIES); + result.context("failed to read ovf-env.xml") +} + +fn parse_ovf_env(xml: &str) -> Result { + let clean = strip_xml_namespaces(xml).context("failed to strip XML namespaces")?; + let env: OvfEnvironment = serde_xml_rs::from_str(&clean).context("failed to parse OVF XML")?; + Ok(env) +} + +/// Strip namespace prefixes from XML elements/attributes via structural parsing. +fn strip_xml_namespaces(xml: &str) -> Result { + let reader = EventReader::from_str(xml); + let mut output = Vec::new(); + let mut writer = EmitterConfig::new() + .perform_indent(false) + .write_document_declaration(false) + .create_writer(&mut output); + + for event in reader { + let event = event.context("failed to read XML event")?; + match event { + ReadEvent::StartElement { + name, attributes, .. + } => { + let mut elem = WriteEvent::start_element(name.local_name.as_str()); + let filtered_attrs: Vec<_> = attributes + .iter() + .filter(|a| { + a.name.prefix.as_deref() != Some("xmlns") && a.name.local_name != "xmlns" + }) + .collect(); + for attr in &filtered_attrs { + elem = elem.attr(attr.name.local_name.as_str(), &attr.value); + } + writer + .write(elem) + .context("failed to write XML start element")?; + } + ReadEvent::EndElement { .. } => { + writer + .write(WriteEvent::end_element()) + .context("failed to write XML end element")?; + } + ReadEvent::Characters(text) => { + writer + .write(WriteEvent::characters(&text)) + .context("failed to write XML characters")?; + } + ReadEvent::CData(text) => { + writer + .write(WriteEvent::cdata(&text)) + .context("failed to write XML CDATA")?; + } + _ => {} + } + } + String::from_utf8(output).context("XML output is not valid UTF-8") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ovf_parse_admin_password() { + let xml = r#" + + + 1.0 + + + + +"#; + let env = parse_ovf_env(xml).unwrap(); + assert_eq!( + env.provisioning_section + .linux_prov_conf_set + .admin_password + .as_str(), + "" + ); + } + + #[test] + fn test_ovf_parse_supports_lowercase_admin_password_tag() { + let xml = r#" + + + 1.0 + + + + +"#; + let env = parse_ovf_env(xml).unwrap(); + assert_eq!( + env.provisioning_section + .linux_prov_conf_set + .admin_password + .as_str(), + "" + ); + } + + #[test] + fn test_ovf_parse_non_empty_admin_password() { + let xml = r#" + + + 1.0 + + SecretPassword123! + + +"#; + let env = parse_ovf_env(xml).unwrap(); + assert_eq!( + env.provisioning_section + .linux_prov_conf_set + .admin_password + .as_str(), + "SecretPassword123!" + ); + } + + #[test] + fn test_hash_admin_password_emits_sha512_crypt() { + let hash = hash_admin_password("SecretPassword123!").unwrap(); + + assert!(hash.starts_with("$6$rounds=10000$")); + let parts: Vec<&str> = hash.splitn(5, '$').collect(); + assert_eq!(parts.len(), 5); + assert_eq!(parts[1], "6"); + assert_eq!(parts[2], "rounds=10000"); + assert_eq!(parts[3].len(), SALT_LEN, "salt should be 16 chars"); + assert!( + parts[3].bytes().all(|b| SALT_ALPHABET.contains(&b)), + "salt should only contain crypt(3) alphabet chars" + ); + assert!(!parts[4].is_empty(), "hash should be non-empty"); + } + + #[test] + fn test_sha512_crypt_is_deterministic_for_fixed_salt() { + let a = sha512_crypt("hunter2", "abcdefghijklmnop", 10_000).unwrap(); + let b = sha512_crypt("hunter2", "abcdefghijklmnop", 10_000).unwrap(); + assert_eq!(a, b); + assert!(a.starts_with("$6$rounds=10000$abcdefghijklmnop$")); + } + + #[test] + fn test_sha512_crypt_distinct_salts_produce_distinct_hashes() { + let a = sha512_crypt("hunter2", "aaaaaaaaaaaaaaaa", 10_000).unwrap(); + let b = sha512_crypt("hunter2", "bbbbbbbbbbbbbbbb", 10_000).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn test_sha512_crypt_distinct_passwords_produce_distinct_hashes() { + // Holding salt and rounds constant, the password input must + // affect the digest. Guards against the FFI silently dropping + // the phrase argument. + let a = sha512_crypt("hunter2", "saltsaltsaltsalt", 10_000).unwrap(); + let b = sha512_crypt("hunter1", "saltsaltsaltsalt", 10_000).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn test_sha512_crypt_distinct_rounds_produce_distinct_hashes() { + // The rounds parameter must be wired through to crypt_r and + // reflected in both the digest and the setting prefix. + let a = sha512_crypt("hunter2", "saltsaltsaltsalt", 5_000).unwrap(); + let b = sha512_crypt("hunter2", "saltsaltsaltsalt", 10_000).unwrap(); + assert_ne!(a, b); + assert!(a.starts_with("$6$rounds=5000$saltsaltsaltsalt$")); + assert!(b.starts_with("$6$rounds=10000$saltsaltsaltsalt$")); + } + + #[test] + fn test_sha512_crypt_matches_drepper_kat_vector() { + // Known-answer test from Ulrich Drepper's SHA-crypt + // specification (test vector with rounds=10000). Confirms our + // libxcrypt FFI shim emits the standard crypt(3) digest that + // `/etc/shadow` / PAM expects. + let hash = sha512_crypt("Hello world!", "saltstringsaltst", 10_000).unwrap(); + assert_eq!( + hash, + "$6$rounds=10000$saltstringsaltst$OW1/O6BYHV6BcXZu8QVeXbDWra3Oeqh0sbHbbMCVNSnCM/UrjmM0Dp8vOuZeHBy/YTBmSK6H9qs/y3RnOaw5v." + ); + } + + #[test] + fn test_sha512_crypt_rejects_nul_in_password() { + // CString::new must reject embedded NUL bytes before we hand + // the phrase to crypt_r; we should surface a hard error, not + // truncate silently. + assert!(sha512_crypt("bad\0pass", "saltsaltsaltsalt", 10_000).is_err()); + } + + #[test] + fn test_sha512_crypt_rejects_nul_in_salt() { + // Same guarantee as for the password, applied to the salt + // argument that is interpolated into the setting string. + assert!(sha512_crypt("hunter2", "bad\0salt12345678", 10_000).is_err()); + } + + #[test] + fn test_generate_salt_alphabet_and_length() { + for _ in 0..16 { + let salt = generate_salt().unwrap(); + assert_eq!(salt.len(), SALT_LEN); + assert!(salt.bytes().all(|b| SALT_ALPHABET.contains(&b))); + } + } +} diff --git a/src/providers/microsoft/azure/mock_tests.rs b/src/providers/microsoft/azure/mock_tests.rs index 734d99a9..59305b26 100644 --- a/src/providers/microsoft/azure/mock_tests.rs +++ b/src/providers/microsoft/azure/mock_tests.rs @@ -169,6 +169,20 @@ fn mock_shared_config(server: &mut mockito::Server) -> mockito::Mock { .create() } +/// https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service +fn mock_imds_admin_username(server: &mut mockito::Server, body: &str) -> mockito::Mock { + server + .mock( + "GET", + "/metadata/instance/compute/osProfile/adminUsername?api-version=2021-02-01&format=text", + ) + .match_header("Metadata", "true") + .with_header("content-type", "text/plain") + .with_body(body) + .with_status(200) + .create() +} + #[test] fn test_boot_checkin() { let mut server = mockito::Server::new(); @@ -362,3 +376,37 @@ fn test_imds_fetch_empty_ssh_keys() { m_imds.assert(); assert!(keys.is_empty()); } + +#[test] +fn test_imds_fetch_admin_username() { + let mut server = mockito::Server::new(); + let _m_version = mock_fab_version(&mut server); + let m_imds = mock_imds_admin_username(&mut server, " azureuser\n"); + + let client = retry::Client::try_new() + .unwrap() + .mock_base_url(server.url()); + let provider = azure::Azure::with_client(Some(client)).unwrap(); + let username = provider.admin_username().unwrap(); + + m_imds.assert(); + // Whitespace and a trailing newline should be trimmed off. + assert_eq!(username.as_deref(), Some("azureuser")); +} + +#[test] +fn test_imds_fetch_admin_username_empty_body() { + let mut server = mockito::Server::new(); + let _m_version = mock_fab_version(&mut server); + let m_imds = mock_imds_admin_username(&mut server, " \n"); + + let client = retry::Client::try_new() + .unwrap() + .mock_base_url(server.url()); + let provider = azure::Azure::with_client(Some(client)).unwrap(); + let username = provider.admin_username().unwrap(); + + m_imds.assert(); + // Whitespace-only payload should be treated as "no admin username". + assert_eq!(username, None); +} diff --git a/src/providers/microsoft/azure/mod.rs b/src/providers/microsoft/azure/mod.rs index 50a045cd..c52fc0e5 100644 --- a/src/providers/microsoft/azure/mod.rs +++ b/src/providers/microsoft/azure/mod.rs @@ -14,6 +14,8 @@ //! Azure provider, metadata and wireserver fetcher. +pub(crate) mod config; + use super::goalstate; use std::collections::HashMap; @@ -89,6 +91,52 @@ struct Attributes { pub dynamic_ipv4: Option, } +#[derive(Debug, Deserialize)] +struct ImdsSshKey { + #[serde(rename = "keyData")] + key_data: String, + #[serde(default)] + path: String, +} + +fn imds_key_path_context(path: &str) -> String { + let value = path.trim(); + if value.is_empty() { + String::new() + } else { + format!(" (path: {value})") + } +} + +pub(crate) fn parse_imds_public_keys(body: &str) -> Result> { + let items: Vec = + serde_json::from_str(body).context("failed to parse IMDS publicKeys JSON")?; + + items + .into_iter() + .enumerate() + .map(|(index, item)| { + let key_data = item.key_data.replace(['\r', '\n'], "").trim().to_string(); + + if key_data.is_empty() { + anyhow::bail!( + "IMDS publicKeys entry at index {}{} has empty keyData", + index, + imds_key_path_context(&item.path), + ); + } + + PublicKey::parse(&key_data).with_context(|| { + format!( + "failed to parse IMDS public key at index {}{}", + index, + imds_key_path_context(&item.path) + ) + }) + }) + .collect() +} + impl Azure { /// Try to build a new provider agent for Azure. /// @@ -282,8 +330,28 @@ impl Azure { Ok(vmsize) } - /// Fetch SSH public keys from Azure Instance Metadata Service (IMDS) - /// https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service + fn fetch_admin_username(&self) -> Result> { + const URL: &str = + "metadata/instance/compute/osProfile/adminUsername?api-version=2021-02-01&format=text"; + let url = format!("{}/{}", Self::metadata_endpoint(), URL); + + let username = self + .client + .clone() + .header( + HeaderName::from_static("metadata"), + HeaderValue::from_static("true"), + ) + .get(retry::Raw, url) + .send::() + .context("failed to query IMDS for adminUsername")?; + + match username { + Some(u) if !u.trim().is_empty() => Ok(Some(u.trim().to_string())), + _ => Ok(None), + } + } + fn fetch_ssh_keys(&self) -> Result> { const URL: &str = "metadata/instance/compute/publicKeys?api-version=2021-02-01"; let url = format!("{}/{}", Self::metadata_endpoint(), URL); @@ -300,32 +368,7 @@ impl Azure { .context("failed to query IMDS for publicKeys")? .ok_or_else(|| anyhow::anyhow!("IMDS did not return a publicKeys payload"))?; - #[derive(Debug, Deserialize)] - struct ImdsSshKey { - #[serde(rename = "keyData")] - key_data: String, - path: String, - } - - let items: Vec = - serde_json::from_str(&body).context("failed to parse IMDS publicKeys JSON")?; - - let keys: Vec = items - .into_iter() - .map(|item| { - let kd = item - .key_data - .replace("\r\n", "") - .replace('\n', "") - .trim() - .to_string(); - - PublicKey::parse(&kd) - .with_context(|| format!("failed to parse IMDS key at path {}", item.path)) - }) - .collect::>()?; - - Ok(keys) + parse_imds_public_keys(&body) } /// Report ready state to the WireServer. @@ -370,6 +413,16 @@ impl MetadataProvider for Azure { self.fetch_hostname() } + fn admin_username(&self) -> Result> { + self.fetch_admin_username() + } + + fn admin_password_hash(&self) -> Result> { + config::read_ovf_admin_password()? + .map(|password| config::hash_admin_password(&password)) + .transpose() + } + fn ssh_keys(&self) -> Result> { self.fetch_ssh_keys() } @@ -384,3 +437,133 @@ impl MetadataProvider for Azure { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_imds_public_keys_rejects_malformed_key() { + let body = r#" +[ + { + "keyData": "not-an-ssh-key", + "path": "/home/core/.ssh/authorized_keys" + } +] +"#; + + let err = parse_imds_public_keys(body).unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("failed to parse IMDS public key")); + assert!(message.contains("/home/core/.ssh/authorized_keys")); + } + + #[test] + fn test_parse_imds_public_keys_accepts_valid_key() { + let body = r#" +[ + { + "keyData": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDYVEprvtYJXVOBN0XNKVVRNCRX6BlnNbI+USLGais1sUWPwtSg7z9K9vhbYAPUZcq8c/s5S9dg5vTHbsiyPCIDOKyeHba4MUJq8Oh5b2i71/3BISpyxTBH/uZDHdslW2a+SrPDCeuMMoss9NFhBdKtDkdG9zyi0ibmCP6yMdEX8Q== Generated by Nova", + "path": "/home/core/.ssh/authorized_keys" + } +] +"#; + + let keys = parse_imds_public_keys(body).unwrap(); + assert_eq!(keys.len(), 1); + assert!(keys[0] + .to_key_format() + .starts_with("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDYVEprvtYJ")); + } + + #[test] + fn test_parse_imds_public_keys_rejects_empty_key_data() { + // Exercises the empty-keyData guard distinct from the + // PublicKey::parse failure path. + let body = r#" +[ + { + "keyData": " ", + "path": "/home/core/.ssh/authorized_keys" + } +] +"#; + + let err = parse_imds_public_keys(body).unwrap_err(); + let message = format!("{err:#}"); + assert!( + message.contains("empty keyData"), + "unexpected error: {message}" + ); + assert!(message.contains("/home/core/.ssh/authorized_keys")); + } + + #[test] + fn test_parse_imds_public_keys_strips_embedded_newlines() { + // Some sources copy keys with hard-wrapped CR/LF newlines; the + // parser must normalize them before handing to PublicKey::parse. + // We inject newlines into a known-valid key (the same one used + // by the mock-tests fixture) so the test exercises real key bytes. + const VALID_KEY: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCzTI2wld1/Ib3q2N8ynJoTk+1PfanCoPdcYXxd7k8DK/T7BlvD3AHajUiqJ4Im2GfyMCloBkJGbI4/utt7qCF6y3Vb5Suxdd5/nXpAr75ocHkg43TzZdU5zSPHdgLefe2VEKXokAobU13wHAwj6d6bJaTpAJx5MkQJAb88HD1LtBFMz5C8b+wVloxB+Zusj0dX/Bc+rZFo62KW50BoaWxDzO5jN18DGuamcN34WBCkMmRvVrKGQaOru+rBVnxeQtVw+hygq7rb6zekar9zyEyW5IvaZiRkqC60QiycV7I9fIxRJfp8FvrlusiVyWpsLILL3CxK95c8Sju4qQN6AofTh52XJtFkdP8ngXKrobXqrcLS5GaEnAo6BZowUt9cpr7HdmdqJmYk3+ueJOrLiAkRE2Gguc28sbpQl/ok4vHWhXEi/GzK+FK0lrN5L7LY5D4MfJ1XZ5sZw4ulJXjiB3x/aKLT0lLFc3lNitl+UPw46Lp1PTR0dJkYNyvZvEuWLgk= core@host"; + + // Splice CRLF mid-base64 and a trailing LF; normalization must + // restore the original key byte-for-byte. + let (head, tail) = VALID_KEY.split_at(60); + let wrapped = format!("{head}\\r\\n{tail}\\n"); + let body = + format!(r#"[{{"keyData": "{wrapped}", "path": "/home/core/.ssh/authorized_keys"}}]"#); + + let keys = parse_imds_public_keys(&body).unwrap(); + assert_eq!(keys.len(), 1); + let serialized = keys[0].to_key_format(); + assert!( + !serialized.contains('\n') && !serialized.contains('\r'), + "serialized key contained line endings: {serialized:?}" + ); + assert!(serialized.starts_with("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ")); + } + + #[test] + fn test_parse_imds_public_keys_preserves_multiple_keys_in_order() { + let body = r#" +[ + { + "keyData": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE5dnA1jzwJZ2ndPmoZAvB1MDLU+UJpw0o9EzGm6tF5Y first@host", + "path": "/home/core/.ssh/authorized_keys" + }, + { + "keyData": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBFmlqemnJgtwQfrqlmwR0sFE+wLDmHGm2I0i/uVy/JJ second@host", + "path": "/home/core/.ssh/authorized_keys" + } +] +"#; + + let keys = parse_imds_public_keys(body).unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys[0].to_key_format().ends_with(" first@host")); + assert!(keys[1].to_key_format().ends_with(" second@host")); + } + + #[test] + fn test_parse_imds_public_keys_handles_missing_path_field() { + // `path` is marked `#[serde(default)]`; payloads that omit it + // should parse and produce an error message without a `(path: )` + // suffix when keyData is invalid. + let body = r#" +[ + { + "keyData": "not-an-ssh-key" + } +] +"#; + + let err = parse_imds_public_keys(body).unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("index 0"), "unexpected error: {message}"); + assert!( + !message.contains("(path:"), + "empty path should not appear in error: {message}" + ); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 63414851..d9e74df9 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -196,6 +196,14 @@ pub trait MetadataProvider { Ok(None) } + fn admin_username(&self) -> Result> { + Ok(None) + } + + fn admin_password_hash(&self) -> Result> { + Ok(None) + } + fn ssh_keys(&self) -> Result> { warn!("ssh-keys requested, but not supported on this platform"); Ok(vec![])