diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index ceb10ec09e..19098cd702 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -707,6 +707,36 @@ "hashedPassword": { "title": "Flag for hashed password (true) or plain text password (false or not defined)", "type": "boolean" + }, + "sshPublicKey": { + "title": "One or more SSH keys", + "anyOf": [ + { + "type": "string", + "title": "Single SSH Key" + }, + { + "type": "array", + "items": { "type": "string" }, + "title": "List of SSH Keys", + "minItems": 1 + } + ] + }, + "sshPublicKeys": { + "title": "One or more SSH keys", + "anyOf": [ + { + "type": "string", + "title": "Single SSH Key" + }, + { + "type": "array", + "items": { "type": "string" }, + "title": "List of SSH Keys", + "minItems": 1 + } + ] } }, "required": ["fullName", "userName", "password"] @@ -725,8 +755,34 @@ "type": "boolean" }, "sshPublicKey": { - "title": "SSH public key", - "type": "string" + "title": "One or more SSH keys", + "anyOf": [ + { + "type": "string", + "title": "Single SSH Key" + }, + { + "type": "array", + "items": { "type": "string" }, + "title": "List of SSH Keys", + "minItems": 1 + } + ] + }, + "sshPublicKeys": { + "title": "One or more SSH keys", + "anyOf": [ + { + "type": "string", + "title": "Single SSH Key" + }, + { + "type": "array", + "items": { "type": "string" }, + "title": "List of SSH Keys", + "minItems": 1 + } + ] } } }, diff --git a/rust/agama-users/src/model.rs b/rust/agama-users/src/model.rs index c1c93d6f42..d9bccb6726 100644 --- a/rust/agama-users/src/model.rs +++ b/rust/agama-users/src/model.rs @@ -128,7 +128,18 @@ impl Model { ))); } - self.set_user_group(user_name)?; + let ssh_keys = user + .ssh_public_key + .as_ref() + .map(|k| k.to_vec()) + .unwrap_or_default(); + + self.activate_ssh( + &PathBuf::from(format!("/home/{}/.ssh", user_name)), + &ssh_keys, + )?; + + let _ = self.set_user_group(user_name); self.set_user_password(user_name, user_password)?; self.update_user_fullname(user) } @@ -144,9 +155,24 @@ impl Model { self.set_user_password("root", root_password)?; } - // store ssh key for root if any - if let Some(ref root_ssh_key) = root.ssh_public_key { - self.update_authorized_keys(root_ssh_key)?; + // store sshPublicKeys for root if any + let ssh_keys = root + .ssh_public_key + .as_ref() + .map(|k| k.to_vec()) + .unwrap_or_default(); + + self.activate_ssh(&PathBuf::from("root/.ssh/authorized_keys"), &ssh_keys)?; + + Ok(()) + } + + fn activate_ssh(&self, path: &PathBuf, ssh_keys: &[String]) -> Result<(), service::Error> { + if !ssh_keys.is_empty() { + // if some SSH keys were defined + // - update authorized_keys file + // - open SSH port and enable SSH service + self.update_authorized_keys(path, ssh_keys)?; self.enable_sshd_service()?; self.open_ssh_port()?; } @@ -210,9 +236,13 @@ impl Model { } /// Updates root's authorized_keys file with SSH key - fn update_authorized_keys(&self, ssh_key: &str) -> Result<(), service::Error> { + fn update_authorized_keys( + &self, + keys_path: &PathBuf, + ssh_keys: &[String], + ) -> Result<(), service::Error> { let mode = 0o644; - let file_name = self.install_dir.join("root/.ssh/authorized_keys"); + let file_name = self.install_dir.join(keys_path); let mut authorized_keys_file = OpenOptions::new() .create(true) .append(true) @@ -223,9 +253,12 @@ impl Model { // sets mode also for an existing file fs::set_permissions(&file_name, Permissions::from_mode(mode))?; - writeln!(authorized_keys_file, "{}", ssh_key.trim())?; - - Ok(()) + ssh_keys + .iter() + .try_for_each(|ssh_key| -> Result<(), service::Error> { + writeln!(authorized_keys_file, "{}", ssh_key.trim())?; + Ok(()) + }) } /// Enables sshd service in the target system diff --git a/rust/agama-utils/src/api/users/config.rs b/rust/agama-utils/src/api/users/config.rs index 3380751ab1..2bd3a7d475 100644 --- a/rust/agama-utils/src/api/users/config.rs +++ b/rust/agama-utils/src/api/users/config.rs @@ -83,6 +83,11 @@ pub struct FirstUserConfig { /// First user's username #[merge(strategy = merge::option::overwrite_none)] pub user_name: Option, + #[merge(strategy = merge::option::overwrite_none)] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "ssh_public_keys")] + #[schema(inline)] + pub ssh_public_key: Option, } impl FirstUserConfig { @@ -125,6 +130,30 @@ fn overwrite_if_not_empty(old: &mut String, new: String) { } } +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, utoipa::ToSchema)] +#[schema(as = StringOrList)] +#[serde(untagged)] +pub enum StringOrList { + Single(String), + List(Vec), +} + +impl StringOrList { + pub fn to_vec(&self) -> Vec { + match self { + StringOrList::Single(s) => vec![s.clone()], + StringOrList::List(v) => v.clone(), + } + } + + pub fn is_empty(&self) -> bool { + match self { + StringOrList::Single(s) => s.is_empty(), + StringOrList::List(v) => v.is_empty(), + } + } +} + /// Root user settings /// /// Holds the settings for the root user. @@ -139,7 +168,9 @@ pub struct RootUserConfig { /// Root SSH public key #[merge(strategy = merge::option::overwrite_none)] #[serde(skip_serializing_if = "Option::is_none")] - pub ssh_public_key: Option, + #[serde(alias = "ssh_public_keys")] + #[schema(inline)] + pub ssh_public_key: Option, } impl RootUserConfig { @@ -152,7 +183,7 @@ impl RootUserConfig { return false; } - if self.ssh_public_key.as_ref().is_some_and(|p| !p.is_empty()) { + if self.ssh_public_key.as_ref().is_some_and(|k| !k.is_empty()) { return false; } @@ -162,7 +193,7 @@ impl RootUserConfig { #[cfg(test)] mod test { - use super::{Config, FirstUserConfig, RootUserConfig, UserPassword}; + use super::{Config, FirstUserConfig, RootUserConfig, StringOrList, UserPassword}; #[test] fn test_parse_user_password() { @@ -234,7 +265,7 @@ mod test { assert!(root_with_empty_password_config.is_empty()); let root_with_ssh_key = RootUserConfig { - ssh_public_key: Some("12345678".to_string()), + ssh_public_key: Some(StringOrList::Single("12345678".to_string())), ..Default::default() }; let root_with_ssh_key_config = Config { @@ -242,6 +273,16 @@ mod test { ..Default::default() }; assert!(!root_with_ssh_key_config.is_empty()); + + let root_with_ssh_keys = RootUserConfig { + ssh_public_key: Some(StringOrList::List(vec!["12345678".to_string()])), + ..Default::default() + }; + let root_with_ssh_keys_config = Config { + root: Some(root_with_ssh_keys), + ..Default::default() + }; + assert!(!root_with_ssh_keys_config.is_empty()); } #[test] @@ -255,6 +296,7 @@ mod test { password: "12345678".to_string(), hashed_password: false, }), + ssh_public_key: None, }; assert!(valid_user.is_valid()); @@ -265,6 +307,7 @@ mod test { password: "12345678".to_string(), hashed_password: false, }), + ssh_public_key: None, }; assert!(!empty_user_name.is_valid()); @@ -275,6 +318,7 @@ mod test { password: "12345678".to_string(), hashed_password: false, }), + ssh_public_key: None, }; assert!(!empty_full_name.is_valid()); @@ -285,7 +329,19 @@ mod test { password: "".to_string(), hashed_password: false, }), + ssh_public_key: None, }; assert!(!empty_password.is_valid()); + + let with_ssh_keys = FirstUserConfig { + user_name: Some("firstuser".to_string()), + ssh_public_key: Some(StringOrList::List(vec!["12345678".to_string()])), + ..Default::default() + }; + let with_ssh_keys_config = Config { + first_user: Some(with_ssh_keys), + ..Default::default() + }; + assert!(!with_ssh_keys_config.is_empty()); } } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 8bc7b2fcd4..5ee288741d 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,12 @@ +------------------------------------------------------------------- +Sat Mar 14 20:15:07 UTC 2026 - Michal Filka + +- jsc#PED-15434 + - support for multiple SSH keys for root even first user in + agama profile + - both sshPublicKey and sshPublicKeys aliases are available and + handled in the same way + ------------------------------------------------------------------- Fri Mar 13 15:51:57 UTC 2026 - Ancor Gonzalez Sosa diff --git a/service/lib/agama/autoyast/root_reader.rb b/service/lib/agama/autoyast/root_reader.rb index ecdb8b78c5..c5add56bd0 100755 --- a/service/lib/agama/autoyast/root_reader.rb +++ b/service/lib/agama/autoyast/root_reader.rb @@ -46,8 +46,7 @@ def read hsh["hashedPassword"] = true if password.value.encrypted? end - public_key = root_user.authorized_keys.first - hsh["sshPublicKey"] = public_key if public_key + hsh = hsh.merge(setup_ssh(root_user)) return {} if hsh.empty? @@ -66,6 +65,17 @@ def config result = reader.read @config = result.config end + + def setup_ssh(root_user) + hsh = {} + + public_key = root_user.authorized_keys.first + + hsh["sshPublicKey"] = public_key if public_key + hsh["sshPublicKeys"] = root_user.authorized_keys unless root_user.authorized_keys.empty? + + hsh + end end end end diff --git a/service/lib/agama/autoyast/user_reader.rb b/service/lib/agama/autoyast/user_reader.rb index 37c3ea0c7d..c79800d46e 100755 --- a/service/lib/agama/autoyast/user_reader.rb +++ b/service/lib/agama/autoyast/user_reader.rb @@ -37,10 +37,7 @@ def read user = config.users.find { |u| !u.system? && !u.root? } return {} unless user - hsh = { - "userName" => user.name, - "fullName" => user.gecos.first.to_s - } + hsh = basic_user_info(user) password = user.password if password @@ -48,6 +45,8 @@ def read hsh["hashedPassword"] = true if password.value.encrypted? end + hsh["sshPublicKeys"] = user.authorized_keys unless user.authorized_keys.empty? + { "user" => hsh } end @@ -63,6 +62,13 @@ def config result = reader.read @config = result.config end + + def basic_user_info(user) + { + "userName" => user.name, + "fullName" => user.gecos.first.to_s + } + end end end end diff --git a/service/test/agama/autoyast/root_reader_test.rb b/service/test/agama/autoyast/root_reader_test.rb index 0fd156a726..1bb011fc36 100644 --- a/service/test/agama/autoyast/root_reader_test.rb +++ b/service/test/agama/autoyast/root_reader_test.rb @@ -71,7 +71,9 @@ it "includes a 'root' key with the root user data" do root = subject.read["root"] expect(root).to eq( - "password" => "123456", "sshPublicKey" => "ssh-key 1" + "password" => "123456", + "sshPublicKey" => "ssh-key 1", + "sshPublicKeys" => ["ssh-key 1", "ssh-key 2"] ) end