libazureinit/src/provision/ssh.rs (428 lines of code) (raw):

// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. //! This module provides functionality for provisioning SSH keys for a user. //! //! It includes functions to create the necessary `.ssh` directory, set the appropriate //! permissions, and write the provided public keys to the `authorized_keys` file. use crate::error::Error; use crate::imds::PublicKeys; use lazy_static::lazy_static; use nix::unistd::{chown, User}; use regex::Regex; use std::{ fs::{ OpenOptions, {File, Permissions}, }, io::{self, Read, Write}, os::unix::fs::{DirBuilderExt, PermissionsExt}, path::PathBuf, process::{Command, Output}, }; use tracing::{error, info, instrument}; lazy_static! { /// A regular expression to match the `PasswordAuthentication` setting in the SSH configuration. static ref PASSWORD_REGEX: Regex = Regex::new( r"(?m)^\s*#?\s*PasswordAuthentication\s+(yes|no)\s*$" ) .expect( "The regular expression is invalid or exceeds the default regex size" ); } /// Provisions SSH keys for the specified user. /// /// Creates the `.ssh` directory in the user's home directory, sets the appropriate /// permissions, and writes the provided public keys to the `authorized_keys` file. /// /// # Arguments /// /// * `user` - A reference to the user for whom the SSH keys are being provisioned. /// * `keys` - A slice of `PublicKeys` to be added to the `authorized_keys` file. /// * `authorized_keys_path_string` - An optional string specifying the path to the `authorized_keys` file. /// /// # Returns /// /// This function returns `Result<(), Error>` indicating success or failure. /// /// # Errors /// /// This function will return an error if it fails to create the `.ssh` directory, set permissions, /// or write to the `authorized_keys` file. #[instrument(skip_all, name = "ssh")] pub(crate) fn provision_ssh( user: &User, keys: &[PublicKeys], authorized_keys_path: PathBuf, query_sshd_config: bool, ) -> Result<(), Error> { let authorized_keys_path = if query_sshd_config { tracing::info!( "Attempting to get authorized keys path via sshd -G as configured." ); match get_authorized_keys_path_from_sshd(|| { Command::new("sshd").arg("-G").output() }) { Some(path) => user.dir.join(path), None => { tracing::warn!("sshd -G failed; using configured authorized_keys_path as fallback."); user.dir.join(authorized_keys_path) } } } else { user.dir.join(authorized_keys_path) }; let ssh_dir = user.dir.join(".ssh"); std::fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(&ssh_dir)?; std::fs::set_permissions(&ssh_dir, Permissions::from_mode(0o700))?; chown(&ssh_dir, Some(user.uid), Some(user.gid))?; tracing::info!( target: "libazureinit::ssh::authorized_keys", "Using authorized_keys path: {:?}", authorized_keys_path ); let mut authorized_keys = File::create(&authorized_keys_path)?; authorized_keys.set_permissions(Permissions::from_mode(0o600))?; keys.iter() .try_for_each(|key| writeln!(authorized_keys, "{}", key.key_data))?; chown(&authorized_keys_path, Some(user.uid), Some(user.gid))?; Ok(()) } /// Retrieves the path to the `authorized_keys` file from the SSH daemon configuration. /// /// Runs the SSH daemon to get the configuration and extracts /// the `AuthorizedKeysFile` setting. /// /// # Arguments /// /// * `sshd_config_command_runner` - A function that runs the SSH daemon command and returns its output. /// /// # Returns /// /// This function returns a path to the `authorized_keys` file if found, /// or `None` if the setting is not found. fn get_authorized_keys_path_from_sshd( sshd_config_command_runner: impl Fn() -> io::Result<Output>, ) -> Option<String> { let output = run_sshd_command(sshd_config_command_runner)?; let path = extract_authorized_keys_file_path(&output.stdout); if path.is_none() { error!("No authorizedkeysfile setting found in sshd configuration"); } path } /// Runs the SSH daemon command to get its configuration. /// /// # Arguments /// /// * `sshd_config_command_runner` - A function that runs the SSH daemon command and returns its output. /// /// # Returns /// /// This function returns an output of the command. fn run_sshd_command( sshd_config_command_runner: impl Fn() -> io::Result<Output>, ) -> Option<Output> { match sshd_config_command_runner() { Ok(output) if output.status.success() => { info!( target: "libazureinit::ssh::success", stdout_length = output.stdout.len(), "Executed sshd -G successfully", ); Some(output) } Ok(output) => { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); error!( code=output.status.code().unwrap_or(-1), stdout=%stdout, stderr=%stderr, "Failed to execute sshd -G, assuming sshd configuration defaults" ); None } Err(e) => { error!( error=%e, "Failed to execute sshd -G, assuming sshd configuration defaults", ); None } } } /// Extracts the `AuthorizedKeysFile` path from the SSH daemon configuration output. /// /// Parses the output of the SSH daemon configuration command and extracts the /// `AuthorizedKeysFile` setting. /// /// # Arguments /// /// * `sshd_config_output` - A byte slice containing the output of the SSH daemon configuration command. /// /// # Returns /// /// This function returns an `Option<String>` containing the path to the `authorized_keys` file if found, /// or `None` if the setting is not found. fn extract_authorized_keys_file_path(stdout: &[u8]) -> Option<String> { let output = String::from_utf8_lossy(stdout); for line in output.lines() { if line.starts_with("authorizedkeysfile") { let keypath = line.split_whitespace().nth(1).map(|s| { info!( target: "libazureinit::ssh::authorized_keys", authorizedkeysfile = %s, "Using sshd's authorizedkeysfile path configuration" ); s.to_string() }); if keypath.is_some() { return keypath; } } } None } /// Updates the SSH daemon configuration to ensure `PasswordAuthentication` is set to `yes`. /// /// Checks if the `sshd_config` file exists and updates the `PasswordAuthentication` /// setting to `yes`. If the file does not exist, it creates a new one with the appropriate setting. /// /// # Arguments /// /// * `sshd_config_path` - A string slice containing the path to the `sshd_config` file. /// /// # Returns /// /// This function returns `Result<(), io::Error>` indicating success or failure. /// /// # Errors /// /// This function will return an error if it fails to read, write, or create the `sshd_config` file. pub(crate) fn update_sshd_config( sshd_config_path: &str, ) -> Result<(), io::Error> { // Check if the path exists otherwise create it let sshd_config_path = PathBuf::from(sshd_config_path); if !sshd_config_path.exists() { let mut file = std::fs::File::create(&sshd_config_path)?; file.set_permissions(Permissions::from_mode(0o600))?; file.write_all(b"PasswordAuthentication yes\n")?; tracing::info!( ?sshd_config_path, "Created new sshd drop-in configuration file" ); return Ok(()); } let mut file_content = String::new(); { let mut file = OpenOptions::new().read(true).open(&sshd_config_path)?; file.read_to_string(&mut file_content)?; } let re = &PASSWORD_REGEX; if re.is_match(&file_content) { let modified_content = re.replace_all( &file_content, "PasswordAuthentication yes # modified by azure-init\n", ); let mut sshd_config = OpenOptions::new() .write(true) .truncate(true) .open(&sshd_config_path)?; sshd_config.write_all(modified_content.as_bytes())?; tracing::info!( ?sshd_config_path, "Updated existing sshd setting to allow password authentication" ); } else { let mut file = OpenOptions::new().append(true).open(&sshd_config_path)?; file.write_all(b"PasswordAuthentication yes # added by azure-init\n")?; tracing::info!( ?sshd_config_path, "Added new sshd setting to allow password authentication" ); } Ok(()) } #[cfg(test)] mod tests { use crate::imds::PublicKeys; use crate::provision::ssh::{ extract_authorized_keys_file_path, get_authorized_keys_path_from_sshd, provision_ssh, run_sshd_command, update_sshd_config, }; use std::{ fs::{File, Permissions}, io::{self, Read, Write}, os::unix::fs::{DirBuilderExt, PermissionsExt}, os::unix::process::ExitStatusExt, process::{ExitStatus, Output}, }; use tempfile::TempDir; fn create_output(status_code: i32, stdout: &str, stderr: &str) -> Output { Output { status: ExitStatus::from_raw(status_code), stdout: stdout.as_bytes().to_vec(), stderr: stderr.as_bytes().to_vec(), } } fn get_test_user_with_home_dir(create_ssh_dir: bool) -> nix::unistd::User { let home_dir = tempfile::TempDir::new().expect("Failed to create temp directory"); let mut user = nix::unistd::User::from_name(whoami::username().as_str()) .expect("Failed to get user") .expect("User does not exist"); user.dir = home_dir.path().into(); if create_ssh_dir { std::fs::DirBuilder::new() .mode(0o700) .create(user.dir.join(".ssh")) .expect("Failed to create .ssh directory"); } user } #[test] fn test_run_sshd_command_success() { let expected_stdout = "authorizedkeysfile .ssh/test_authorized_keys"; let mock_runner = || Ok(create_output(0, expected_stdout, "some stderr")); let result = run_sshd_command(mock_runner); assert!(result.is_some()); assert_eq!( String::from_utf8_lossy(&result.unwrap().stdout), expected_stdout ); } #[test] fn test_run_sshd_command_failure() { let stdout = "authorizedkeysfile .ssh/test_authorized_keys"; let mock_runner = || Ok(create_output(1, stdout, "Error running sshd -G")); let result = run_sshd_command(mock_runner); assert!(result.is_none()); } #[test] fn test_run_sshd_command_error() { let mock_runner = || { Err(io::Error::new(io::ErrorKind::NotFound, "command not found")) }; let result = run_sshd_command(mock_runner); assert!(result.is_none()); } #[test] fn test_get_authorized_keys_path_from_sshd_success() { let test_cases = vec![ ( "authorizedkeysfile .ssh/authorized_keys", Some(".ssh/authorized_keys"), ), ( "authorizedkeysfile .ssh/other_authorized_keys", Some(".ssh/other_authorized_keys"), ), ( "authorizedkeysfile /custom/path/to/keys", Some("/custom/path/to/keys"), ), ("# No authorizedkeysfile line here", None), // Case with no match ]; for (stdout, expected_path) in test_cases { let mock_runner = || Ok(create_output(0, stdout, "some stderr")); let result: Option<Output> = run_sshd_command(mock_runner); assert!(result.is_some(), "Expected a successful command output"); let output: Output = result.unwrap(); let stdout_str = String::from_utf8_lossy(&output.stdout); assert_eq!(stdout_str, stdout); let extracted_path: Option<String> = extract_authorized_keys_file_path(&output.stdout); assert_eq!( extracted_path, expected_path.map(|s| s.to_string()), "Expected path extraction to match for stdout: {}", stdout ); } } #[test] fn test_get_authorized_keys_path_from_sshd_no_authorized_keys() { let mock_runner = || Ok(create_output(0, "no authorizedkeysfile here", "")); let result = get_authorized_keys_path_from_sshd(mock_runner); assert!(result.is_none()); } #[test] fn test_get_authorized_keys_path_from_sshd_command_fails() { // Mock sshd command runner that simulates a failed command execution let mock_runner = || Err(io::Error::new(io::ErrorKind::Other, "command error")); let result = get_authorized_keys_path_from_sshd(mock_runner); assert!(result.is_none()); } #[test] fn test_extract_authorized_keys_file_path_valid() { let stdout = b"authorizedkeysfile .ssh/test_authorized_keys\n"; let result = extract_authorized_keys_file_path(stdout); assert_eq!(result, Some(".ssh/test_authorized_keys".to_string())); } #[test] fn test_extract_authorized_keys_file_path_invalid() { let stdout = b"some irrelevant output\n"; let result = extract_authorized_keys_file_path(stdout); assert!(result.is_none()); } // Test that we set the permission bits correctly on the ssh files; sadly it's difficult to test // chown without elevated permissions. #[test] fn test_provision_ssh() { let user = get_test_user_with_home_dir(false); 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 authorized_keys_path = user.dir.join(".ssh/xauthorized_keys"); provision_ssh(&user, &keys, authorized_keys_path, false).unwrap(); let ssh_path = user.dir.join(".ssh"); let ssh_dir = std::fs::File::open(&ssh_path).unwrap(); let mut auth_file = std::fs::File::open(&ssh_path.join("xauthorized_keys")).unwrap(); let mut buf = String::new(); auth_file.read_to_string(&mut buf).unwrap(); assert_eq!("not-a-real-key abc123\nnot-a-real-key xyz987\n", buf); // Refer to man 7 inode for details on the mode - 100000 is a regular file, 040000 is a directory assert_eq!( ssh_dir.metadata().unwrap().permissions(), Permissions::from_mode(0o040700) ); assert_eq!( auth_file.metadata().unwrap().permissions(), Permissions::from_mode(0o100600) ); } // Test that if the .ssh directory already exists, we handle it gracefully. This can occur if, for example, // /etc/skel includes it. This also checks that we fix the permissions if /etc/skel has been mis-configured. #[test] fn test_pre_existing_ssh_dir() { let user = get_test_user_with_home_dir(true); 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 authorized_keys_path = user.dir.join(".ssh/xauthorized_keys"); provision_ssh(&user, &keys, authorized_keys_path, false).unwrap(); let ssh_dir = std::fs::File::open(user.dir.join(".ssh")).unwrap(); assert_eq!( ssh_dir.metadata().unwrap().permissions(), Permissions::from_mode(0o040700) ); } // Test that any pre-existing authorized_keys are overwritten. #[test] fn test_pre_existing_authorized_keys() { let user = get_test_user_with_home_dir(true); 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 authorized_keys_path = user.dir.join(".ssh/xauthorized_keys"); provision_ssh(&user, &keys[1..], authorized_keys_path.clone(), false) .unwrap(); provision_ssh(&user, &keys[1..], authorized_keys_path.clone(), false) .unwrap(); let mut auth_file = std::fs::File::open(user.dir.join(".ssh/xauthorized_keys")) .unwrap(); let mut buf = String::new(); auth_file.read_to_string(&mut buf).unwrap(); assert_eq!("not-a-real-key xyz987\n", buf); } #[test] fn test_update_sshd_config_create_new() -> io::Result<()> { let temp_dir = TempDir::new().unwrap(); let sshd_config_path = temp_dir.path().join("sshd_config"); let ret: Result<(), io::Error> = update_sshd_config(sshd_config_path.to_str().unwrap()); assert!(ret.is_ok()); let mut updated_content = String::new(); let mut file = File::open(&sshd_config_path).unwrap(); file.read_to_string(&mut updated_content).unwrap(); assert!(updated_content.contains("PasswordAuthentication yes")); Ok(()) } #[test] fn test_update_sshd_config_change() -> io::Result<()> { let temp_dir = TempDir::new()?; let sshd_config_path = temp_dir.path().join("sshd_config"); { let mut file = File::create(&sshd_config_path)?; writeln!(file, "PasswordAuthentication no")?; } let ret: Result<(), io::Error> = update_sshd_config(sshd_config_path.to_str().unwrap()); assert!(ret.is_ok()); let mut updated_content = String::new(); { let mut file = File::open(&sshd_config_path)?; file.read_to_string(&mut updated_content)?; } assert!(updated_content.contains("PasswordAuthentication yes")); assert!(!updated_content.contains("PasswordAuthentication no")); Ok(()) } #[test] fn test_update_sshd_config_no_change() -> io::Result<()> { let temp_dir = TempDir::new()?; let sshd_config_path = temp_dir.path().join("sshd_config"); { let mut file = File::create(&sshd_config_path)?; writeln!(file, "PasswordAuthentication yes")?; } let ret: Result<(), io::Error> = update_sshd_config(sshd_config_path.to_str().unwrap()); assert!(ret.is_ok()); let mut updated_content = String::new(); { let mut file = File::open(&sshd_config_path)?; file.read_to_string(&mut updated_content)?; } assert!(updated_content.contains("PasswordAuthentication yes")); assert!(!updated_content.contains("PasswordAuthentication no")); Ok(()) } }