diff --git a/libazureinit/build.rs b/libazureinit/build.rs index 9f7fe22..b27de5f 100644 --- a/libazureinit/build.rs +++ b/libazureinit/build.rs @@ -6,6 +6,5 @@ fn main() { println!("cargo:rustc-env=PATH_HOSTNAMECTL=hostnamectl"); println!("cargo:rustc-env=PATH_USERADD=useradd"); // The list of supplementary groups to add a provisioned user to. - println!("cargo:rustc-env=USERADD_GROUPS=adm,audio,cdrom,dialout,dip,floppy,lxd,netdev,plugdev,sudo,video"); println!("cargo:rustc-env=PATH_PASSWD=passwd"); } diff --git a/libazureinit/src/lib.rs b/libazureinit/src/lib.rs index a88764f..d5b5b67 100644 --- a/libazureinit/src/lib.rs +++ b/libazureinit/src/lib.rs @@ -10,7 +10,8 @@ mod provision; pub use provision::{ hostname::Provisioner as HostnameProvisioner, password::Provisioner as PasswordProvisioner, - user::Provisioner as UserProvisioner, Provision, + user::{Provisioner as UserProvisioner, User}, + Provision, }; // Re-export as the Client is used in our API. diff --git a/libazureinit/src/provision/mod.rs b/libazureinit/src/provision/mod.rs index 5bff6ff..44a05c9 100644 --- a/libazureinit/src/provision/mod.rs +++ b/libazureinit/src/provision/mod.rs @@ -8,7 +8,7 @@ pub mod user; use strum::IntoEnumIterator; use tracing::instrument; -use crate::{error::Error, imds::PublicKeys}; +use crate::error::Error; /// The interface for applying the desired configuration to the host. /// @@ -23,24 +23,17 @@ use crate::{error::Error, imds::PublicKeys}; #[derive(Default, Clone)] pub struct Provision { hostname: String, - username: String, - keys: Vec, - password: Option, + user: user::User, hostname_backends: Option>, user_backends: Option>, password_backends: Option>, } impl Provision { - pub fn new( - hostname: impl Into, - username: impl Into, - ssh_keys: impl Into>, - ) -> Self { + pub fn new(hostname: impl Into, user: user::User) -> Self { Self { hostname: hostname.into(), - username: username.into(), - keys: ssh_keys.into(), + user, ..Default::default() } } @@ -85,12 +78,6 @@ impl Provision { self } - /// Set the given password for the provisioned user. - pub fn password(mut self, password: String) -> Self { - self.password = Some(password); - self - } - /// Provision the host. #[instrument(skip_all)] pub fn provision(self) -> Result<(), Error> { @@ -99,7 +86,7 @@ impl Provision { .iter() .find_map(|backend| { backend - .create(&self.username) + .create(&self.user) .map_err(|e| { tracing::info!( error=?e, @@ -118,7 +105,7 @@ impl Provision { .iter() .find_map(|backend| { backend - .set(&self.username, self.password.as_deref().unwrap_or("")) + .set(&self.user) .map_err(|e| { tracing::info!( error=?e, @@ -132,13 +119,13 @@ impl Provision { }) .ok_or(Error::NoPasswordProvisioner)?; - if !self.keys.is_empty() { - let user = nix::unistd::User::from_name(&self.username)?.ok_or( + if !self.user.ssh_keys.is_empty() { + let user = nix::unistd::User::from_name(&self.user.name)?.ok_or( Error::UserMissing { - user: self.username, + user: self.user.name, }, )?; - ssh::provision_ssh(&user, &self.keys)?; + ssh::provision_ssh(&user, &self.user.ssh_keys)?; } self.hostname_backends @@ -167,20 +154,17 @@ impl Provision { #[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(), - "my-user".to_string(), - vec![], - ) - .hostname_provisioners([hostname::Provisioner::FakeHostnamectl]) - .user_provisioners([user::Provisioner::FakeUseradd]) - .password("password".to_string()) - .password_provisioners([password::Provisioner::FakePasswd]) - .provision() - .unwrap(); + let _p = Provision::new("my-hostname".to_string(), User::default()) + .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 index 547d7f0..8f9a173 100644 --- a/libazureinit/src/provision/password.rs +++ b/libazureinit/src/provision/password.rs @@ -5,7 +5,7 @@ use std::process::Command; use tracing::instrument; -use crate::error::Error; +use crate::{error::Error, User}; #[derive(strum::EnumIter, Debug, Clone)] #[non_exhaustive] @@ -16,13 +16,9 @@ pub enum Provisioner { } impl Provisioner { - pub(crate) fn set( - &self, - username: impl AsRef, - password: impl AsRef, - ) -> Result<(), Error> { + pub(crate) fn set(&self, user: &User) -> Result<(), Error> { match self { - Self::Passwd => passwd(username.as_ref(), password.as_ref()), + Self::Passwd => passwd(user), #[cfg(test)] Self::FakePasswd => Ok(()), } @@ -30,12 +26,14 @@ impl Provisioner { } #[instrument(skip_all)] -fn passwd(username: &str, password: &str) -> Result<(), Error> { +fn passwd(user: &User) -> Result<(), Error> { let path_passwd = env!("PATH_PASSWD"); - if password.is_empty() { - let status = - Command::new(path_passwd).arg("-d").arg(username).status()?; + 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(), diff --git a/libazureinit/src/provision/user.rs b/libazureinit/src/provision/user.rs index bc8ecef..c036d23 100644 --- a/libazureinit/src/provision/user.rs +++ b/libazureinit/src/provision/user.rs @@ -5,7 +5,69 @@ use std::process::Command; use tracing::instrument; -use crate::error::Error; +use crate::{error::Error, imds::PublicKeys}; + +/// Configuration for the provisioned user. +#[derive(Clone)] +pub struct User { + pub(crate) name: String, + pub(crate) groups: Vec, + pub(crate) ssh_keys: Vec, + pub(crate) password: Option, +} + +impl Default for User { + fn default() -> Self { + Self { + name: "azureuser".into(), + password: Default::default(), + groups: vec!["wheel".into()], + ssh_keys: vec![], + } + } +} + +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. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } + + /// A list of SSH public keys to add to the user's authorized_keys file. + pub fn ssh_keys(mut self, ssh_keys: impl Into>) -> Self { + self.ssh_keys = ssh_keys.into(); + self + } + + /// 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 + } +} #[derive(strum::EnumIter, Debug, Clone)] #[non_exhaustive] @@ -16,9 +78,9 @@ pub enum Provisioner { } impl Provisioner { - pub(crate) fn create(&self, name: impl AsRef) -> Result<(), Error> { + pub(crate) fn create(&self, user: &User) -> Result<(), Error> { match self { - Self::Useradd => useradd(name.as_ref()), + Self::Useradd => useradd(user), #[cfg(test)] Self::FakeUseradd => Ok(()), } @@ -26,18 +88,18 @@ impl Provisioner { } #[instrument(skip_all)] -fn useradd(name: &str) -> Result<(), Error> { +fn useradd(user: &User) -> Result<(), Error> { let path_useradd = env!("PATH_USERADD"); - let home_path = format!("/home/{name}"); + let home_path = format!("/home/{}", user.name); let status = Command::new(path_useradd) - .arg(name) + .arg(&user.name) .arg("--comment") .arg( "Provisioning agent created this user based on username provided in IMDS", ) .arg("--groups") - .arg(env!("USERADD_GROUPS")) + .arg(user.groups.join(",")) .arg("-d") .arg(home_path) .arg("-m") @@ -51,3 +113,25 @@ fn useradd(name: &str) -> Result<(), Error> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::imds::PublicKeys; + + use super::User; + + #[test] + fn test_default_user_with_ssh() { + let keys = vec![ + PublicKeys { + key_data: "not-a-real-key abc123".to_string(), + path: "unused".to_string(), + }, + PublicKeys { + key_data: "not-a-real-key xyz987".to_string(), + path: "unused".to_string(), + }, + ]; + let _user = User::default().ssh_keys(keys); + } +} diff --git a/src/main.rs b/src/main.rs index 99466aa..167fab8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use std::process::ExitCode; use anyhow::Context; use libazureinit::imds::InstanceMetadata; +use libazureinit::User; use libazureinit::{ error::Error as LibError, goalstate, imds, media, @@ -87,26 +88,23 @@ async fn provision() -> Result<(), anyhow::Error> { let instance_metadata = imds::query(&client).await?; let username = get_username(&instance_metadata, &get_environment()?)?; + let user = + User::new(username).ssh_keys(instance_metadata.compute.public_keys); - Provision::new( - instance_metadata.compute.os_profile.computer_name, - username, - instance_metadata.compute.public_keys, - ) - .hostname_provisioners([ - #[cfg(feature = "hostnamectl")] - HostnameProvisioner::Hostnamectl, - ]) - .user_provisioners([ - #[cfg(feature = "useradd")] - UserProvisioner::Useradd, - ]) - .password("".to_string()) - .password_provisioners([ - #[cfg(feature = "passwd")] - PasswordProvisioner::Passwd, - ]) - .provision()?; + 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 3cfde94..989d5c8 100644 --- a/tests/functional_tests.rs +++ b/tests/functional_tests.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use libazureinit::imds::PublicKeys; +use libazureinit::User; use libazureinit::{ goalstate, reqwest::{header, Client}, @@ -66,13 +67,15 @@ async fn main() { }, ]; - Provision::new("my-hostname".to_string(), username.to_string(), keys) - .hostname_provisioners([HostnameProvisioner::Hostnamectl]) - .user_provisioners([UserProvisioner::Useradd]) - .password("".to_string()) - .password_provisioners([PasswordProvisioner::Passwd]) - .provision() - .expect("Failed to provision host"); + Provision::new( + "my-hostname".to_string(), + User::new(username).ssh_keys(keys), + ) + .hostname_provisioners([HostnameProvisioner::Hostnamectl]) + .user_provisioners([UserProvisioner::Useradd]) + .password_provisioners([PasswordProvisioner::Passwd]) + .provision() + .expect("Failed to provision host"); println!("VM successfully provisioned"); println!();