From 624ce3ef4f56afb4a6b495dc953f1684ef3de256 Mon Sep 17 00:00:00 2001 From: Jeremy Cline Date: Tue, 9 Jul 2024 20:02:28 +0000 Subject: [PATCH] Define a User type for holding user provisioning details The list of supplementary groups isn't portable and needs to be exposed in the public API somehow. Rather than expand the Provision API to include it directly, add a User type that holds the username, password, ssh keys, and supplementary group information and accept that in the provision interface. To start with I left the default group list at "wheel" since it covers most distributions and if the user is in wheel they can add themselves to the other groups if they need it later. --- libazureinit/build.rs | 1 - libazureinit/src/lib.rs | 3 +- libazureinit/src/provision/mod.rs | 52 +++++--------- libazureinit/src/provision/password.rs | 20 +++--- libazureinit/src/provision/user.rs | 98 ++++++++++++++++++++++++-- src/main.rs | 36 +++++----- tests/functional_tests.rs | 17 +++-- 7 files changed, 147 insertions(+), 80 deletions(-) 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!();