diff --git a/Cargo.toml b/Cargo.toml index 2b1a59e..7689cd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,12 @@ path = "tests/functional_tests.rs" members = [ "libazureinit", ] + +[features] +passwd = [] +hostnamectl = [] +useradd = [] + +systemd_linux = ["passwd", "hostnamectl", "useradd"] + +default = ["systemd_linux"] diff --git a/libazureinit/Cargo.toml b/libazureinit/Cargo.toml index 59241dd..08d28ed 100644 --- a/libazureinit/Cargo.toml +++ b/libazureinit/Cargo.toml @@ -18,6 +18,7 @@ serde_json = "1.0.96" nix = {version = "0.29.0", features = ["fs", "user"]} block-utils = "0.11.1" tracing = "0.1.40" +strum = { version = "0.26.3", features = ["derive"] } [dev-dependencies] tempfile = "3" diff --git a/libazureinit/src/error.rs b/libazureinit/src/error.rs index f0ebd23..cc96626 100644 --- a/libazureinit/src/error.rs +++ b/libazureinit/src/error.rs @@ -31,4 +31,16 @@ pub enum Error { NonEmptyPassword, #[error("Unable to get list of block devices")] BlockUtils(#[from] block_utils::BlockUtilsError), + #[error( + "Failed to set the hostname; none of the provided backends succeeded" + )] + NoHostnameProvisioner, + #[error( + "Failed to create a user; none of the provided backends succeeded" + )] + NoUserProvisioner, + #[error( + "Failed to set the user password; none of the provided backends succeeded" + )] + NoPasswordProvisioner, } diff --git a/libazureinit/src/imds.rs b/libazureinit/src/imds.rs index 65451d5..69831b5 100644 --- a/libazureinit/src/imds.rs +++ b/libazureinit/src/imds.rs @@ -57,6 +57,15 @@ pub struct PublicKeys { pub path: String, } +impl From<&str> for PublicKeys { + fn from(value: &str) -> Self { + Self { + key_data: value.to_string(), + path: String::new(), + } + } +} + /// Deserializer that handles the string "true" and "false" that the IMDS API returns. fn string_bool<'de, D>(deserializer: D) -> Result where diff --git a/libazureinit/src/lib.rs b/libazureinit/src/lib.rs index 04c0cb9..d5b5b67 100644 --- a/libazureinit/src/lib.rs +++ b/libazureinit/src/lib.rs @@ -1,12 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -pub mod distro; pub mod error; pub mod goalstate; pub mod imds; pub mod media; -pub mod user; + +mod provision; +pub use provision::{ + hostname::Provisioner as HostnameProvisioner, + password::Provisioner as PasswordProvisioner, + user::{Provisioner as UserProvisioner, User}, + Provision, +}; // Re-export as the Client is used in our API. pub use reqwest; diff --git a/libazureinit/src/provision/hostname.rs b/libazureinit/src/provision/hostname.rs new file mode 100644 index 0000000..5f66515 --- /dev/null +++ b/libazureinit/src/provision/hostname.rs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::process::Command; + +use tracing::instrument; + +use crate::error::Error; + +/// Available tools to set the host's hostname. +#[derive(strum::EnumIter, Debug, Clone)] +#[non_exhaustive] +pub enum Provisioner { + /// Use the `hostnamectl` command from `systemd`. + Hostnamectl, + #[cfg(test)] + FakeHostnamectl, +} + +impl Provisioner { + pub(crate) fn set(&self, hostname: impl AsRef) -> Result<(), Error> { + match self { + Self::Hostnamectl => hostnamectl(hostname.as_ref()), + #[cfg(test)] + Self::FakeHostnamectl => Ok(()), + } + } +} + +#[instrument(skip_all)] +fn hostnamectl(hostname: &str) -> Result<(), Error> { + let path_hostnamectl = env!("PATH_HOSTNAMECTL"); + + let status = Command::new(path_hostnamectl) + .arg("set-hostname") + .arg(hostname) + .status()?; + if status.success() { + Ok(()) + } else { + Err(Error::SubprocessFailed { + command: path_hostnamectl.to_string(), + status, + }) + } +} diff --git a/libazureinit/src/provision/mod.rs b/libazureinit/src/provision/mod.rs new file mode 100644 index 0000000..d51ca0c --- /dev/null +++ b/libazureinit/src/provision/mod.rs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +pub mod hostname; +pub mod password; +pub(crate) mod ssh; +pub mod user; + +use strum::IntoEnumIterator; +use tracing::instrument; + +use crate::error::Error; +use crate::User; + +/// The interface for applying the desired configuration to the host. +/// +/// By default, all known tools for provisioning a particular resource are tried +/// until one succeeds. Particular tools can be selected via the +/// `*_provisioners()` methods ([`Provision::hostname_provisioners`], +/// [`Provision::user_provisioners`], etc). +/// +/// To actually apply the configuration, use [`Provision::provision`]. +#[derive(Clone)] +pub struct Provision { + hostname: String, + user: User, + hostname_backends: Option>, + user_backends: Option>, + password_backends: Option>, +} + +impl Provision { + pub fn new(hostname: impl Into, user: User) -> Self { + Self { + hostname: hostname.into(), + user, + hostname_backends: None, + user_backends: None, + password_backends: None, + } + } + + /// Specify the ways to set the virtual machine's hostname. + /// + /// By default, all known methods will be attempted. Use this function to + /// restrict which methods are attempted. These will be attempted in the + /// order provided until one succeeds. + pub fn hostname_provisioners( + mut self, + backends: impl Into>, + ) -> Self { + self.hostname_backends = Some(backends.into()); + self + } + + /// Specify the ways to create a user in the virtual machine + /// + /// By default, all known methods will be attempted. Use this function to + /// restrict which methods are attempted. These will be attempted in the + /// order provided until one succeeds. + pub fn user_provisioners( + mut self, + backends: impl Into>, + ) -> Self { + self.user_backends = Some(backends.into()); + self + } + + /// Specify the ways to set a users password. + /// + /// By default, all known methods will be attempted. Use this function to + /// restrict which methods are attempted. These will be attempted in the + /// order provided until one succeeds. Only relevant if a password has been + /// provided with the [`User`]. + pub fn password_provisioners( + mut self, + backend: impl Into>, + ) -> Self { + self.password_backends = Some(backend.into()); + self + } + + /// Provision the host. + /// + /// Provisioning can fail if the host lacks the necessary tools. For example, + /// if there is no `useradd` command on the system's `PATH`, or if the command + /// returns an error, this will return an error. It does not attempt to undo + /// partial provisioning. + #[instrument(skip_all)] + pub fn provision(self) -> Result<(), Error> { + self.user_backends + .unwrap_or_else(|| user::Provisioner::iter().collect()) + .iter() + .find_map(|backend| { + backend + .create(&self.user) + .map_err(|e| { + tracing::info!( + error=?e, + backend=?backend, + resource="user", + "Provisioning did not succeed" + ); + e + }) + .ok() + }) + .ok_or(Error::NoUserProvisioner)?; + + self.password_backends + .unwrap_or_else(|| password::Provisioner::iter().collect()) + .iter() + .find_map(|backend| { + backend + .set(&self.user) + .map_err(|e| { + tracing::info!( + error=?e, + backend=?backend, + resource="password", + "Provisioning did not succeed" + ); + e + }) + .ok() + }) + .ok_or(Error::NoPasswordProvisioner)?; + + if !self.user.ssh_keys.is_empty() { + let user = nix::unistd::User::from_name(&self.user.name)?.ok_or( + Error::UserMissing { + user: self.user.name, + }, + )?; + ssh::provision_ssh(&user, &self.user.ssh_keys)?; + } + + self.hostname_backends + .unwrap_or_else(|| hostname::Provisioner::iter().collect()) + .iter() + .find_map(|backend| { + backend + .set(&self.hostname) + .map_err(|e| { + tracing::info!( + error=?e, + backend=?backend, + resource="hostname", + "Provisioning did not succeed" + ); + e + }) + .ok() + }) + .ok_or(Error::NoHostnameProvisioner)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use crate::User; + + use super::{hostname, password, user, Provision}; + + #[test] + fn test_successful_provision() { + let _p = Provision::new( + "my-hostname".to_string(), + User::new("azureuser", vec![]), + ) + .hostname_provisioners([hostname::Provisioner::FakeHostnamectl]) + .user_provisioners([user::Provisioner::FakeUseradd]) + .password_provisioners([password::Provisioner::FakePasswd]) + .provision() + .unwrap(); + } +} diff --git a/libazureinit/src/provision/password.rs b/libazureinit/src/provision/password.rs new file mode 100644 index 0000000..3be8f3f --- /dev/null +++ b/libazureinit/src/provision/password.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::process::Command; + +use tracing::instrument; + +use crate::{error::Error, User}; + +/// Available tools to set the user's password (if a password is provided). +#[derive(strum::EnumIter, Debug, Clone)] +#[non_exhaustive] +pub enum Provisioner { + /// Use the `passwd` command from `shadow-utils`. + Passwd, + #[cfg(test)] + FakePasswd, +} + +impl Provisioner { + pub(crate) fn set(&self, user: &User) -> Result<(), Error> { + match self { + Self::Passwd => passwd(user), + #[cfg(test)] + Self::FakePasswd => Ok(()), + } + } +} + +#[instrument(skip_all)] +fn passwd(user: &User) -> Result<(), Error> { + let path_passwd = env!("PATH_PASSWD"); + + if user.password.is_none() { + let status = Command::new(path_passwd) + .arg("-d") + .arg(&user.name) + .status()?; + if !status.success() { + return Err(Error::SubprocessFailed { + command: path_passwd.to_string(), + status, + }); + } + } else { + // creating user with a non-empty password is not allowed. + return Err(Error::NonEmptyPassword); + } + + Ok(()) +} diff --git a/libazureinit/src/user.rs b/libazureinit/src/provision/ssh.rs similarity index 94% rename from libazureinit/src/user.rs rename to libazureinit/src/provision/ssh.rs index 31c541e..e31c937 100644 --- a/libazureinit/src/user.rs +++ b/libazureinit/src/provision/ssh.rs @@ -12,19 +12,6 @@ use tracing::instrument; use crate::error::Error; use crate::imds::PublicKeys; -pub fn set_ssh_keys( - keys: Vec, - username: impl AsRef, -) -> Result<(), Error> { - let user = - nix::unistd::User::from_name(username.as_ref())?.ok_or_else(|| { - Error::UserMissing { - user: username.as_ref().to_string(), - } - })?; - provision_ssh(&user, &keys) -} - #[instrument(skip_all, name = "ssh")] pub(crate) fn provision_ssh( user: &nix::unistd::User, diff --git a/libazureinit/src/provision/user.rs b/libazureinit/src/provision/user.rs new file mode 100644 index 0000000..eb6c156 --- /dev/null +++ b/libazureinit/src/provision/user.rs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::process::Command; + +use tracing::instrument; + +use crate::{error::Error, imds::PublicKeys}; + +/// The user and its related configuration to create on the host. +/// +/// A bare minimum user includes a name and a set of SSH public keys to allow the user to +/// log into the host. Additional configuration includes a set of supplementary groups to +/// add the user to, and a password to set for the user. +/// +/// By default, the user is included in the `wheel` group which is often used to +/// grant administrator privileges via the `sudo` command. This can be changed with the +/// [`User::with_groups`] method. +/// +/// # Example +/// +/// ``` +/// # use libazureinit::User; +/// let user = User::new("azure-user", ["ssh-ed25519 NOTAREALKEY".into()]) +/// .with_groups(["wheel".to_string(), "dialout".to_string()]); +/// ``` +#[derive(Clone)] +pub struct User { + pub(crate) name: String, + pub(crate) groups: Vec, + pub(crate) ssh_keys: Vec, + pub(crate) password: Option, +} + +impl core::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // This is manually implemented to avoid printing the password if it's set + f.debug_struct("User") + .field("name", &self.name) + .field("groups", &self.groups) + .field("ssh_keys", &self.ssh_keys) + .field("password", &self.password.is_some()) + .finish() + } +} + +impl User { + /// Configure the user being provisioned on the host. + /// + /// What constitutes a valid username depends on the host configuration and + /// no validation will occur prior to provisioning the host. + pub fn new( + name: impl Into, + ssh_keys: impl Into>, + ) -> Self { + Self { + name: name.into(), + groups: vec!["wheel".into()], + ssh_keys: ssh_keys.into(), + password: None, + } + } + + /// Set a password for the user; this is optional. + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + /// A list of supplemental group names to add the user to. + /// + /// If any of the groups do not exist on the host, provisioning will fail. + pub fn with_groups(mut self, groups: impl Into>) -> Self { + self.groups = groups.into(); + self + } +} + +/// Available tools to create the user. +#[derive(strum::EnumIter, Debug, Clone)] +#[non_exhaustive] +pub enum Provisioner { + /// Use the `useradd` command from `shadow-utils`. + Useradd, + #[cfg(test)] + FakeUseradd, +} + +impl Provisioner { + pub(crate) fn create(&self, user: &User) -> Result<(), Error> { + match self { + Self::Useradd => useradd(user), + #[cfg(test)] + Self::FakeUseradd => Ok(()), + } + } +} + +#[instrument(skip_all)] +fn useradd(user: &User) -> Result<(), Error> { + let path_useradd = env!("PATH_USERADD"); + let home_path = format!("/home/{}", user.name); + + let status = Command::new(path_useradd) + .arg(&user.name) + .arg("--comment") + .arg( + "Provisioning agent created this user based on username provided in IMDS", + ) + .arg("--groups") + .arg(user.groups.join(",")) + .arg("-d") + .arg(home_path) + .arg("-m") + .status()?; + if !status.success() { + return Err(Error::SubprocessFailed { + command: path_useradd.to_string(), + status, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::User; + + #[test] + fn password_skipped_in_debug() { + let user_with_password = + User::new("azureuser", &[]).with_password("hunter2"); + let user_without_password = User::new("azureuser", &[]); + + assert_eq!( + "User { name: \"azureuser\", groups: [\"wheel\"], ssh_keys: [], password: true }", + format!("{:?}", user_with_password) + ); + assert_eq!( + "User { name: \"azureuser\", groups: [\"wheel\"], ssh_keys: [], password: false }", + format!("{:?}", user_without_password) + ); + } +} diff --git a/src/main.rs b/src/main.rs index 66bb4f6..3eb1a77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,13 +6,13 @@ use std::process::ExitCode; use anyhow::Context; use libazureinit::imds::InstanceMetadata; +use libazureinit::User; use libazureinit::{ - distro, error::Error as LibError, goalstate, imds, media, media::Environment, reqwest::{header, Client}, - user, + HostnameProvisioner, PasswordProvisioner, Provision, UserProvisioner, }; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -88,24 +88,22 @@ async fn provision() -> Result<(), anyhow::Error> { let instance_metadata = imds::query(&client).await?; let username = get_username(&instance_metadata, &get_environment()?)?; - - let mut file_path = "/home/".to_string(); - file_path.push_str(username.as_str()); - - // always pass an empty password - distro::create_user_with_useradd(username.as_str()) - .with_context(|| format!("Unabled to create user '{username}'"))?; - distro::set_password_with_passwd(username.as_str(), "").with_context( - || format!("Unabled to set an empty password for user '{username}'"), - )?; - - user::set_ssh_keys(instance_metadata.compute.public_keys, &username) - .with_context(|| "Failed to write ssh public keys.")?; - - distro::set_hostname_with_hostnamectl( - instance_metadata.compute.os_profile.computer_name.as_str(), - ) - .with_context(|| "Failed to set hostname.")?; + let user = User::new(username, instance_metadata.compute.public_keys); + + Provision::new(instance_metadata.compute.os_profile.computer_name, user) + .hostname_provisioners([ + #[cfg(feature = "hostnamectl")] + HostnameProvisioner::Hostnamectl, + ]) + .user_provisioners([ + #[cfg(feature = "useradd")] + UserProvisioner::Useradd, + ]) + .password_provisioners([ + #[cfg(feature = "passwd")] + PasswordProvisioner::Passwd, + ]) + .provision()?; let vm_goalstate = goalstate::get_goalstate(&client) .await diff --git a/tests/functional_tests.rs b/tests/functional_tests.rs index 23511be..f49256a 100644 --- a/tests/functional_tests.rs +++ b/tests/functional_tests.rs @@ -2,10 +2,11 @@ // Licensed under the MIT License. use libazureinit::imds::PublicKeys; +use libazureinit::User; use libazureinit::{ - distro, goalstate, + goalstate, reqwest::{header, Client}, - user, + HostnameProvisioner, PasswordProvisioner, Provision, UserProvisioner, }; use std::env; @@ -51,22 +52,6 @@ async fn main() { let username = &cli_args[1]; - let mut file_path = "/home/".to_string(); - file_path.push_str(username.as_str()); - - println!(); - println!( - "Attempting to create user {} without password", - username.as_str() - ); - - distro::create_user_with_useradd(username.as_str()) - .expect("Failed to create user for user '{username}'"); - distro::set_password_with_passwd(username.as_str(), "") - .expect("Unabled to set an empty passord for user '{username}'"); - - println!("User {} was successfully created", username.as_str()); - let keys: Vec = vec![ PublicKeys { path: "/path/to/.ssh/keys/".to_owned(), @@ -82,18 +67,14 @@ async fn main() { }, ]; - println!(); - println!("Attempting to create user's SSH authorized_keys"); - - user::set_ssh_keys(keys, username).unwrap(); - - println!(); - println!("Attempting to set the VM hostname"); - - distro::set_hostname_with_hostnamectl("test-hostname-set") - .expect("Failed to set hostname"); + Provision::new("my-hostname".to_string(), User::new(username, keys)) + .hostname_provisioners([HostnameProvisioner::Hostnamectl]) + .user_provisioners([UserProvisioner::Useradd]) + .password_provisioners([PasswordProvisioner::Passwd]) + .provision() + .expect("Failed to provision host"); - println!("VM hostname successfully set"); + println!("VM successfully provisioned"); println!(); println!("**********************************");