//! Linux user administration (port of `users/userAdminService.js`, Linux part). //! //! All remote work runs the fixed `remoteUsers.sh` through the PrivilegedExecutor. Account //! changes are refused for root, system accounts and the active session account; changes that //! could remove the last working administrator are refused. SSH policy changes are staged, //! verified with `sshd -T`, reloaded, confirmed (with a fresh test login for the session //! account) and rolled back automatically otherwise. use super::common::*; use super::keys; use super::policy::{self, AccessContext, Effective, Policies, Policy, MANAGED_PATH}; use crate::core::error::CoreResult; use crate::core::logging::Logger; use crate::core::ssh::privileged::{Frame, Interaction, PrivilegedExecutor, RunOptions, RunResult}; use crate::core::ssh::session::SessionManager; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; pub const REMOTE_SCRIPT: &str = include_str!("../../../resources/remote/remoteUsers.sh"); const SSH_WATCHDOG_SECONDS: u64 = 120; const FORBIDDEN_HOMES: [&str; 14] = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"]; const ACCOUNT_ACTIONS: [&str; 13] = ["lock", "unlock", "expire", "unexpire", "password", "shell", "comment", "home", "primary", "group-add", "group-remove", "sudo-grant", "sudo-revoke"]; /// Actions that can remove the last working administrator. const ADMIN_REDUCING: [&str; 5] = ["delete", "lock", "expire", "sudo-revoke", "shell"]; fn alias(action: &str) -> &str { match action { "grantAdmin" => "sudo-grant", "revokeAdmin" => "sudo-revoke", "setPassword" => "password", "setShell" => "shell", "setComment" => "comment", "setHome" => "home", "setPrimaryGroup" => "primary", "addGroup" => "group-add", "removeGroup" => "group-remove", other => other, } } pub fn validate_name(value: &Value, code: &str) -> CoreResult { let name = js_string(value).trim().to_string(); if !policy::is_policy_name(&name) { return Err(fail(code, "Names must start with a lowercase letter or underscore and contain only a-z, 0-9, _ or - (max. 32).")); } Ok(name) } static SHELL: Lazy = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+-]{1,127}$").unwrap()); static HOME: Lazy = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/@+-]{1,254}$").unwrap()); static HOME_BAD: Lazy = Lazy::new(|| Regex::new(r"/\.\.?(/|$)|//").unwrap()); pub fn validate_shell(value: &Value) -> CoreResult { let shell = js_string(value).trim().to_string(); if !SHELL.is_match(&shell) || shell.contains("/../") || shell.ends_with("/..") { return Err(fail("USERS_SHELL_INVALID", "Invalid shell path.")); } Ok(shell) } pub fn validate_home(value: &Value) -> CoreResult { let home = js_string(value).trim().trim_end_matches('/').to_string(); if !HOME.is_match(&home) || HOME_BAD.is_match(&home) { return Err(fail("USERS_HOME_INVALID", "The home directory must be an absolute path without special characters.")); } if FORBIDDEN_HOMES.iter().any(|root| home == *root || (*root != "/var" && home.starts_with(&format!("{root}/")))) { return Err(fail("USERS_HOME_INVALID", "System directories cannot be used as a home directory.")); } Ok(home) } pub fn validate_comment(value: &Value) -> CoreResult { let comment = js_string(value).trim().to_string(); if comment.encode_utf16().count() > 100 || comment.chars().any(|c| c == ':' || c == '\\' || (c as u32) < 0x20 || c as u32 == 0x7f) { return Err(fail("USERS_COMMENT_INVALID", "The full name may contain up to 100 characters without ':' or control characters.")); } Ok(comment) } pub fn validate_account_password(value: &Value) -> CoreResult { let password = js_string(value); if password.is_empty() || password.encode_utf16().count() > 256 || password.contains(['\0', '\r', '\n']) { return Err(fail("USERS_PASSWORD_INVALID", "The password must contain 1-256 characters and no line breaks.")); } Ok(password) } struct Managed { state: &'static str, sha256: Option, content: String, policies: Policies, consistent: bool, } fn parse_managed_section(text: &str) -> Managed { let values = key_values(text); let empty = |state, consistent| Managed { state, sha256: None, content: String::new(), policies: Policies::new(), consistent }; if values.get("unsafe").map(String::as_str) == Some("1") { return empty("unsafe", false); } if values.get("unreadable").map(String::as_str) == Some("1") { return empty("unreadable", false); } match values.get("sha256").map(String::as_str) { None | Some("absent") => Managed { state: "absent", sha256: Some("absent".into()), content: String::new(), policies: Policies::new(), consistent: true }, Some(sha) => { let content = String::from_utf8_lossy(&B64.decode(values.get("data").map(String::as_str).unwrap_or("")).unwrap_or_default()).to_string(); let parsed = policy::parse_managed_config(&content); Managed { state: "present", sha256: Some(sha.to_string()), content, policies: parsed.policies, consistent: parsed.consistent } } } } struct Passwd { username: String, uid: Option, gid: i64, comment: String, home: String, shell: String, } fn parse_passwd(line: &str) -> Passwd { let parts: Vec<&str> = line.split(':').collect(); let field = |i: usize| parts.get(i).copied().unwrap_or(""); Passwd { username: field(0).to_string(), uid: js_number(field(2)).filter(|n| n.fract() == 0.0).map(|n| n as i64), gid: js_number(field(3)).unwrap_or(f64::NAN) as i64, comment: clean_text(field(4).split(',').next().unwrap_or(""), 100), home: clean_text(field(5), 255), shell: clean_text(field(6), 255), } } impl Passwd { fn value(&self) -> Map { let mut map = Map::new(); map.insert("username".into(), json!(self.username)); map.insert("uid".into(), json!(self.uid)); map.insert("gid".into(), json!(self.gid)); map.insert("comment".into(), json!(self.comment)); map.insert("home".into(), json!(self.home)); map.insert("shell".into(), json!(self.shell)); map } } fn parse_shadow_state(value: &str) -> Map { let parts: Vec<&str> = value.split(':').collect(); let state = parts.first().copied().unwrap_or("unknown"); let last = parts.get(1).copied().unwrap_or(""); let expire = parts.get(2).copied().unwrap_or(""); let expire_days = if expire.is_empty() { None } else { js_number(expire).filter(|n| n.is_finite()) }; let mut map = Map::new(); map.insert("password".into(), json!(if ["set", "locked", "none", "empty"].contains(&state) { state } else { "unknown" })); map.insert("lastChangeDays".into(), if last.is_empty() { Value::Null } else { js_number(last).map(Value::from).unwrap_or(Value::Null) }); map.insert("expireDays".into(), expire_days.map(Value::from).unwrap_or(Value::Null)); map.insert("expired".into(), json!(expire_days.is_some_and(|d| d * 86_400_000.0 <= chrono::Utc::now().timestamp_millis() as f64))); map } fn unknown_shadow(expired: Value) -> Map { let mut map = Map::new(); map.insert("password".into(), json!("unknown")); map.insert("lastChangeDays".into(), Value::Null); map.insert("expireDays".into(), Value::Null); map.insert("expired".into(), expired); map } pub fn is_login_shell(shell: &str) -> bool { !shell.is_empty() && !shell.ends_with("/nologin") && !shell.ends_with("/false") } #[derive(Debug, Clone, Default)] pub struct StepOutcome { pub steps: Vec, pub result: String, pub sudo: Option, } pub fn parse_step_markers(stdout: &str) -> StepOutcome { static STEP: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n").unwrap()); static RESULT: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_RESULT=([a-z]+)").unwrap()); static SUDO: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_SUDO=([a-z-]+)").unwrap()); StepOutcome { steps: STEP.captures_iter(stdout).map(|c| json!({ "step": &c[1], "status": &c[2], "message": clean_text(&c[3], 300) })).collect(), result: RESULT.captures(stdout).map(|c| c[1].to_string()).unwrap_or_else(|| "unknown".into()), sudo: SUDO.captures(stdout).map(|c| c[1].to_string()), } } /// `\x1eCHJ_=\n` blocks up to the next `\x1eCHJ_` or the end. pub fn parse_effective_blocks(text: &str, marker: &str) -> BTreeMap { let tag = format!("\x1eCHJ_{marker}="); let mut blocks = BTreeMap::new(); let mut rest = text; while let Some(start) = rest.find(&tag) { let after = &rest[start + tag.len()..]; let Some(newline) = after.find('\n') else { break }; let key = &after[..newline]; let body_start = &after[newline + 1..]; let end = body_start.find("\x1eCHJ_").unwrap_or(body_start.len()); if !key.is_empty() { blocks.insert(key.to_string(), policy::parse_effective(&body_start[..end])); } rest = &body_start[end..]; } blocks } const ACCESS_LIST_KEYS: [&str; 4] = ["denyusers", "allowusers", "denygroups", "allowgroups"]; fn comparable_access(effective: &Effective, context: &AccessContext) -> String { let evaluation = policy::evaluate_access(effective, context); let settings: Vec<(&String, &Value)> = effective.values.iter().filter(|(k, _)| !ACCESS_LIST_KEYS.contains(&k.as_str())).collect(); serde_json::to_string(&json!({ "allowed": evaluation.allowed_value(), "methods": evaluation.methods, "settings": settings })).unwrap_or_default() } /// Inventory with internal fields kept out of IPC. pub struct Inventory { pub public: Value, pub policies: Policies, } /// Detail with internal fields kept out of IPC. pub struct Detail { pub public: Value, pub key_files: Vec, managed: Managed, addresses: Addresses, groups: Vec, } pub struct LinuxUsers { pub sessions: Arc, pub executor: Arc, pub logger: Arc, } pub struct Auth { pub privileged: bool, pub sudo_password: Option, } fn sudo_password(payload: &Value) -> Option { match payload.get("sudoPassword") { Some(Value::String(s)) if !s.is_empty() => Some(s.clone()), _ => None, } } impl LinuxUsers { async fn run(&self, session_id: &str, args: Vec, frames: Vec, auth: &Auth, purpose: &str, timeout_ms: u64) -> CoreResult { self.executor.run(session_id, self.options(args, frames, auth, purpose, timeout_ms)).await } fn options(&self, args: Vec, frames: Vec, auth: &Auth, purpose: &str, timeout_ms: u64) -> RunOptions { RunOptions { script: REMOTE_SCRIPT.to_string(), args, frames, privileged: auth.privileged, sudo_password: auth.sudo_password.clone(), purpose: purpose.to_string(), error_prefix: "USERS_".into(), timeout: Duration::from_millis(timeout_ms), ..Default::default() } } fn wants_privilege(privilege: &Value, options: &Value) -> bool { sudo_password(options).is_some() || options["privileged"] == true || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or("")) } pub async fn privileges(&self, session_id: &str, refresh: bool) -> CoreResult { let probe = self.executor.probe(session_id, refresh).await?; let mut out = Map::new(); out.insert("platform".into(), json!("linux")); if let Some(map) = probe.as_object() { for (k, v) in map { out.insert(k.clone(), v.clone()); } } Ok(Value::Object(out)) } /// Lists accounts. Without privileges only public account data is read; shadow state and the /// effective sudo rights need root and are fetched automatically when no password is required. pub async fn inventory(&self, session_id: &str, options: &Value) -> CoreResult { let privilege = self.executor.probe(session_id, false).await?; let privileged = Self::wants_privilege(&privilege, options); let run = self.run(session_id, vec!["inventory".into()], vec![], &Auth { privileged, sudo_password: sudo_password(options) }, "users.inventory", 90_000).await?; let privilege = self.executor.probe(session_id, false).await?; Ok(self.parse_inventory(session_id, &run.stdout, privileged, privilege)) } fn parse_inventory(&self, session_id: &str, stdout: &str, privileged: bool, privilege: Value) -> Inventory { let sections = split_sections(stdout); let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or(""); let meta = key_values(section("meta")); let mut tools: Vec = lines(section("meta")).into_iter().filter_map(|l| l.strip_prefix("tool=").map(str::to_string)).collect(); tools.sort(); tools.dedup(); let uid_min = meta.get("uidMin").and_then(|v| js_number(v)).filter(|n| *n != 0.0 && n.is_finite()).unwrap_or(1000.0) as i64; let groups: Vec<(String, i64, Vec)> = lines(section("group")) .into_iter() .map(|line| { let parts: Vec<&str> = line.split(':').collect(); let members = parts.get(3).copied().unwrap_or("").split(',').filter(|s| !s.is_empty()).map(str::to_string).collect(); (parts.first().copied().unwrap_or("").to_string(), js_number(parts.get(2).copied().unwrap_or("")).unwrap_or(f64::NAN) as i64, members) }) .filter(|(name, _, _)| !name.is_empty()) .collect(); let group_by_gid: BTreeMap = groups.iter().map(|(n, g, _)| (*g, n.clone())).collect(); let shadow: BTreeMap> = lines(section("shadow")) .into_iter() .map(|line| { let (name, rest) = line.split_once(':').unwrap_or((line, "")); (name.to_string(), parse_shadow_state(rest)) }) .collect(); let sudo: BTreeMap = lines(section("sudo")).into_iter().map(|l| { let (a, b) = l.split_once(':').unwrap_or((l, "")); (a.to_string(), b.split(':').next().unwrap_or("").to_string()) }).collect(); let admin_groups: Vec = lines(section("admingroups")).into_iter().map(str::to_string).collect(); let managed = parse_managed_section(section("managed")); let session_user = privilege["username"].as_str().unwrap_or("").to_string(); let mut users: Vec = lines(section("passwd")) .into_iter() .map(parse_passwd) .filter(|u| !u.username.is_empty() && u.uid.is_some()) .map(|user| { let uid = user.uid.unwrap_or(0); let state = shadow.get(&user.username).cloned().unwrap_or_else(|| unknown_shadow(Value::Null)); let supplementary: Vec = groups.iter().filter(|(_, _, m)| m.contains(&user.username)).map(|(n, _, _)| n.clone()).collect(); let primary = group_by_gid.get(&user.gid).cloned(); let member_admin: Vec<&str> = ["sudo", "wheel", "admin"].into_iter().filter(|n| supplementary.iter().any(|s| s == n) || primary.as_deref() == Some(*n)).collect(); let sudo_state = if privileged { sudo.get(&user.username).cloned().unwrap_or_else(|| if uid == 0 { "full".into() } else { "unknown".into() }) } else { "unknown".to_string() }; let system = uid != 0 && (uid < uid_min || uid == 65534); let ssh_policy = managed.policies.get(&user.username).map(Policy::to_value).unwrap_or(Value::Null); let mut map = user.value(); map.insert("displayName".into(), json!(user.comment)); map.insert("primaryGroup".into(), json!(primary.clone().unwrap_or_else(|| user.gid.to_string()))); map.insert("groups".into(), json!(supplementary)); map.insert("system".into(), json!(system)); map.insert("loginShell".into(), json!(is_login_shell(&user.shell))); for (k, v) in &state { map.insert(k.clone(), v.clone()); } map.insert("locked".into(), json!(state.get("password").and_then(Value::as_str) == Some("locked"))); map.insert("passwordState".into(), state.get("password").cloned().unwrap_or(json!("unknown"))); map.insert("sudo".into(), json!(sudo_state)); map.insert("adminGroups".into(), json!(member_admin)); map.insert("admin".into(), json!(uid == 0 || if sudo_state == "unknown" { !member_admin.is_empty() } else { sudo_state.starts_with("full") })); map.insert("sshPolicy".into(), ssh_policy); map.insert("protection".into(), if uid == 0 { json!("root") } else if system { json!("system") } else if user.username == session_user { json!("session") } else { Value::Null }); Value::Object(map) }) .collect(); users.sort_by(|a, b| { (a["system"] == true).cmp(&(b["system"] == true)).then(a["uid"].as_i64().cmp(&b["uid"].as_i64())).then_with(|| policy::locale_cmp(a["username"].as_str().unwrap_or(""), b["username"].as_str().unwrap_or(""))) }); let shells: Vec = lines(section("shells")).into_iter().filter(|s| SHELL.is_match(s)).map(str::to_string).collect(); let public = json!({ "platform": "linux", "sessionId": session_id, "privileged": privileged, "privilege": privilege, "server": { "os": clean_text(meta.get("os").map(String::as_str).unwrap_or(""), 120), "osId": clean_text(meta.get("osId").map(String::as_str).unwrap_or(""), 40), "uidMin": uid_min, "tools": tools, "sshLayout": meta.get("layout").cloned().unwrap_or_else(|| "unknown".into()), "managedPath": MANAGED_PATH }, "shells": shells, "groups": groups.iter().map(|(n, g, m)| json!({ "name": n, "gid": g, "members": m })).collect::>(), "adminGroups": admin_groups, "managedPolicy": { "state": managed.state, "consistent": managed.consistent, "users": managed.policies.keys().collect::>() }, "users": users, "collectedAt": now_iso() }); Inventory { public, policies: managed.policies } } pub async fn list_compat(&self, session_id: &str) -> CoreResult { let inventory = self.inventory(session_id, &json!({})).await?; Ok(Value::Array(inventory.public["users"].as_array().cloned().unwrap_or_default().into_iter().filter(|u| u["uid"] == 0 || u["system"] != true).collect())) } pub async fn detail_public(&self, session_id: &str, options: &Value) -> CoreResult { let username = validate_name(&options["username"], "USERS_NAME_INVALID")?; let privilege = self.executor.probe(session_id, false).await?; let privileged = Self::wants_privilege(&privilege, options); Ok(self.detail(session_id, &username, &Auth { privileged, sudo_password: sudo_password(options) }).await?.public) } pub async fn detail(&self, session_id: &str, username: &str, auth: &Auth) -> CoreResult { let privilege = self.executor.probe(session_id, false).await?; let session = self.sessions.session_auth_info(session_id)?; let addresses = family_addresses(privilege["clientAddress"].as_str().unwrap_or("")); let run = self.run(session_id, vec!["detail".into(), username.into(), addresses.ipv4.clone(), addresses.ipv6.clone()], vec![], auth, "users.detail", 60_000).await?; let sections = split_sections(&run.stdout); let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or(""); let account_values = key_values(section("account")); let account = parse_passwd(account_values.get("passwd").map(String::as_str).unwrap_or("")); if account.username != username { return Err(fail("USERS_PROTOCOL_ERROR", "The server returned an unexpected account.")); } let groups: Vec = account_values.get("groups").map(String::as_str).unwrap_or("").split(',').filter(|s| !s.is_empty()).map(str::to_string).collect(); let state = account_values.get("shadow").filter(|s| !s.is_empty()).map(|s| parse_shadow_state(s)).unwrap_or_else(|| unknown_shadow(Value::Null)); let uid_min = account_values.get("uidMin").and_then(|v| js_number(v)).filter(|n| *n != 0.0 && n.is_finite()).unwrap_or(1000.0) as i64; let uid = account.uid.unwrap_or(0); let system = uid != 0 && (uid < uid_min || uid == 65534); let ssh_values = key_values(section("ssh")); let managed = parse_managed_section(section("managed")); let policy = managed.policies.get(username).cloned().unwrap_or_default(); let mut effective = Map::new(); let mut warnings: Vec = Vec::new(); let mut listener: Option = None; let mut keys_command: Option = None; let mut processed = false; for (family, label) in [("ipv4", "effective4"), ("ipv6", "effective6")] { let Some(text) = sections.get(label) else { continue }; processed = true; let parsed = policy::parse_effective(text); let address = addresses.get(family); let evaluation = policy::evaluate_access(&parsed, &AccessContext { username, groups: &groups, address: &address, method: None, keyboard_interactive: true }); effective.insert( family.into(), json!({ "address": address, "sample": addresses.sample(family), "passwordAuthentication": parsed.get("passwordauthentication").unwrap_or("unknown"), "kbdInteractiveAuthentication": parsed.get("kbdinteractiveauthentication").or(parsed.get("challengeresponseauthentication")).unwrap_or("unknown"), "pubkeyAuthentication": parsed.get("pubkeyauthentication").unwrap_or("unknown"), "authenticationMethods": parsed.get("authenticationmethods").unwrap_or("any"), "permitRootLogin": parsed.get("permitrootlogin").unwrap_or("unknown"), "allowed": evaluation.allowed_value(), "reasons": evaluation.reasons, "methods": evaluation.methods, "errors": parsed.errors.iter().take(5).collect::>() }), ); if listener.is_none() { listener = Some(policy::listener_families(&parsed)); } if keys_command.is_none() { if let Some(command) = parsed.get("authorizedkeyscommand").filter(|c| *c != "none") { keys_command = Some(clean_text(command, 200)); } } } if let Some(l) = &listener { effective.insert("listener".into(), l.clone()); if l["ipv6"] == false { warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy.".into()); } if l["ipv4"] == false { warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy.".into()); } } if processed { effective.insert("authorizedKeysCommand".into(), keys_command.clone().map(Value::from).unwrap_or(Value::Null)); } if keys_command.is_some() { warnings.push("AuthorizedKeysCommand is configured; keys may also come from an external source that CH-J does not manage.".into()); } if managed.state == "present" && !managed.consistent { warnings.push(format!("{MANAGED_PATH} was edited manually; CH-J will not overwrite it.")); } let key_files = parse_key_files(section("keys"), &account.home); let self_account = username == privilege["username"].as_str().unwrap_or(""); let mut account_map = account.value(); account_map.insert("primaryGroup".into(), json!(groups.first().cloned().unwrap_or_else(|| account.gid.to_string()))); account_map.insert("groups".into(), json!(groups.iter().skip(1).collect::>())); account_map.insert("system".into(), json!(system)); account_map.insert("loginShell".into(), json!(is_login_shell(&account.shell))); account_map.insert("processes".into(), account_values.get("processes").map(|p| js_number(p).map(Value::from).unwrap_or(Value::Null)).unwrap_or(Value::Null)); account_map.insert("homeMeta".into(), json!(clean_text(account_values.get("homeMeta").map(String::as_str).unwrap_or(""), 120))); for (k, v) in &state { account_map.insert(k.clone(), v.clone()); } account_map.insert("sudo".into(), json!(account_values.get("sudo").filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| "unknown".into()))); account_map.insert("protection".into(), if uid == 0 { json!("root") } else if system { json!("system") } else if self_account { json!("session") } else { Value::Null }); let ssh_layout = ssh_values.get("layout").cloned().unwrap_or_else(|| "unknown".into()); let public = json!({ "platform": "linux", "sessionId": session_id, "username": username, "privileged": run.privileged && auth.privileged, "self": self_account, "session": { "authMethod": session["authMethod"], "username": privilege["username"], "clientAddress": privilege["clientAddress"] }, "account": Value::Object(account_map), "ssh": { "available": ssh_values.get("sshd").is_some_and(|v| v != "missing"), "version": clean_text(ssh_values.get("version").map(String::as_str).unwrap_or(""), 160), "layout": ssh_layout, "layoutSupported": ssh_layout == "ok", "service": clean_text(ssh_values.get("service").map(String::as_str).unwrap_or(""), 120), "syntaxOk": ssh_values.get("syntax").map(|s| json!(s == "0")).unwrap_or(Value::Null), "syntaxMessage": clean_text(ssh_values.get("syntaxMessage").map(String::as_str).unwrap_or(""), 400), "managed": { "state": managed.state, "consistent": managed.consistent, "path": MANAGED_PATH }, "policy": policy.to_value(), "effective": Value::Object(effective), "warnings": warnings }, "keys": { "files": key_files.iter().map(KeyFile::public).collect::>(), "home": account.home }, "collectedAt": now_iso() }); Ok(Detail { public, key_files, managed, addresses, groups }) } pub async fn manage(&self, session_id: &str, payload: &Value) -> CoreResult { let requested = js_string(&payload["action"]); let action = alias(&requested).to_string(); let sudo = sudo_password(payload); if action == "create" { return self.create(session_id, payload, sudo).await; } if action != "delete" && !ACCOUNT_ACTIONS.contains(&action.as_str()) { return Err(fail("USERS_ACTION_INVALID", "Unsupported user-management action.")); } let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?; let args = Self::account_args(&action, &username, payload)?; let options = json!({ "sudoPassword": sudo, "privileged": true }); let before = self.inventory(session_id, &options).await?; let user = before.public["users"].as_array().and_then(|u| u.iter().find(|c| c["username"] == username.as_str())).cloned().ok_or_else(|| fail("USERS_USER_NOT_FOUND", "The account does not exist on the server."))?; Self::assert_mutable(&user, &action)?; if ADMIN_REDUCING.contains(&action.as_str()) { Self::assert_not_last_admin(&before.public, &user, &action, payload)?; } if action == "group-remove" { let group = before.public["groups"].as_array().and_then(|g| g.iter().find(|g| g["name"] == args[3].as_str())).cloned(); if group.is_some_and(|g| g["gid"] == user["gid"]) { return Err(fail("USERS_GROUP_PRIMARY", "The primary group cannot be removed; change the primary group first.")); } } let frames = if action == "password" { vec![Frame::text("SECRET", &validate_account_password(&payload["password"])?)] } else { vec![] }; let auth = Auth { privileged: true, sudo_password: sudo.clone() }; let run = self.run(session_id, args, frames, &auth, &format!("users.{action}"), 60_000).await?; let mut outcome = parse_step_markers(&run.stdout); let mut warnings: Vec = Vec::new(); if action == "delete" && before.policies.contains_key(&username) { match self.apply_policy_change(session_id, &username, None, &auth, &json!({}), false).await { Ok(_) => outcome.steps.push(json!({ "step": "ssh-policy", "status": "ok", "message": "CH-J SSH rules for the account were removed." })), Err(error) => { warnings.push(format!("CH-J SSH rules for the deleted account remain: {}", error.message)); outcome.result = "partial".into(); } } } if let Some(sudo_state) = &outcome.sudo { if action == "sudo-grant" && !sudo_state.starts_with("full") { warnings.push("The account was added to the administrator group, but sudo -l does not report full sudo rights. sudoers was not changed.".into()); } if action == "sudo-revoke" && sudo_state != "none" && sudo_state != "unavailable" { warnings.push(format!("sudo rights remain ({sudo_state}) because they are granted by another sudoers rule. CH-J does not edit sudoers.")); } } self.logger.warn("Remote user-management action completed.", json!({ "sessionId": session_id, "action": action, "username": username, "result": outcome.result })); let inventory = self.inventory(session_id, &options).await?; Ok(json!({ "action": requested, "username": username, "result": outcome.result, "steps": outcome.steps, "warnings": warnings, "sudo": outcome.sudo, "inventory": inventory.public, "users": inventory.public["users"] })) } pub async fn keys(&self, session_id: &str, payload: &Value) -> CoreResult { let action = js_string(&payload["action"]); if action != "add" && action != "remove" { return Err(fail("USERS_ACTION_INVALID", "Unsupported key action.")); } let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?; let session = self.sessions.session_auth_info(session_id)?; let privilege = self.executor.probe(session_id, false).await?; let self_account = username == privilege["username"].as_str().unwrap_or(""); let sudo = sudo_password(payload); let privileged = !self_account || sudo.is_some() || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or("")); let auth = Auth { privileged, sudo_password: sudo }; let detail = self.detail(session_id, &username, &auth).await?; // root's SSH keys are managed (via root session or sudo); other system accounts stay read-only. if detail.public["account"]["system"] == true { return Err(fail("USERS_PROTECTED_USER", "Keys of system accounts are shown read-only.")); } let (file, content) = if action == "add" { let key = keys::normalize_public_key(&payload["publicKey"], payload.get("comment").unwrap_or(&Value::Null))?; for candidate in &detail.key_files { if candidate.entries.iter().any(|e| e.blob.as_deref() == Some(key.blob.as_str())) { return Err(fail("USERS_KEY_DUPLICATE", &format!("This public key is already authorized in {}.", candidate.path))); } } // New keys go only to the first AuthorizedKeysFile entry; CH-J never falls back silently. let file = detail.key_files.first().filter(|f| f.manageable).ok_or_else(|| { let reason = detail.key_files.first().and_then(|f| f.reason).unwrap_or("not found"); fail("USERS_KEYS_FILE_UNMANAGEABLE", &format!("The primary authorized_keys file cannot be managed safely ({reason}). See diagnostics.")) })?; (file, keys::append_key(&file.raw, &key)?) } else { let fingerprint = js_string(&payload["fingerprint"]); let line = js_number_value(&payload["line"]); let file = detail .key_files .iter() .find(|f| Some(f.path.as_str()) == payload["path"].as_str()) .or_else(|| detail.key_files.iter().find(|f| f.entries.iter().any(|e| Some(e.line as f64) == line && e.fingerprint.as_deref() == Some(fingerprint.as_str())))) .filter(|f| f.manageable) .ok_or_else(|| fail("USERS_KEYS_FILE_UNMANAGEABLE", "This key file cannot be changed safely."))?; if self_account && session["authMethod"] == "privateKey" { if session["publicKeyFingerprint"].as_str().is_some_and(|fp| fp == fingerprint) { return Err(fail("USERS_KEY_IN_USE", "This key is used by the active CH-J session and is not removed.")); } let remaining: usize = detail.key_files.iter().map(|f| f.entries.iter().filter(|e| e.valid).count()).sum(); if remaining <= 1 { return Err(fail("USERS_KEY_LAST", "The last authorized key of the active session account is not removed.")); } } (file, keys::remove_key(&file.raw, &payload["line"], &fingerprint)?) }; let expected = if file.kind == "file" { keys::sha256_hex(&file.raw) } else { "absent".into() }; let frames = vec![Frame { tag: "DATA".into(), data: content }]; self.run(session_id, vec!["keys-write".into(), username.clone(), file.path.clone(), expected], frames, &auth, &format!("users.keys.{action}"), 30_000).await?; self.logger.warn("Remote authorized_keys changed.", json!({ "sessionId": session_id, "username": username, "action": action, "path": file.path })); let path = file.path.clone(); let refreshed = self.detail(session_id, &username, &auth).await?; Ok(json!({ "action": action, "username": username, "path": path, "detail": refreshed.public })) } pub async fn ssh_policy(&self, session_id: &str, payload: &Value) -> CoreResult { let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?; let policy = policy::normalize_policy(payload.get("policy").filter(|p| p.is_object()).unwrap_or(&json!({})), Some(&username))?; // Password login for root is never enabled implicitly: it needs a separate explicit confirmation. if username == "root" && (policy.password == "yes" || policy.permit_root_login == "yes") && payload["confirmRootPassword"] != true { return Err(fail("USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED", "Allowing password login for root requires an explicit additional confirmation.")); } let auth = Auth { privileged: true, sudo_password: sudo_password(payload) }; let credentials = payload.get("probeCredentials").filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({})); let mut result = self.apply_policy_change(session_id, &username, Some(policy), &auth, &credentials, true).await?; let detail = self.detail(session_id, &username, &auth).await?; result["detail"] = detail.public; Ok(result) } fn account_args(action: &str, username: &str, payload: &Value) -> CoreResult> { let u = username.to_string(); Ok(match action { "delete" => vec!["delete".into(), u, if payload["removeHome"] == true { "1" } else { "0" }.into()], "shell" => vec!["account".into(), "shell".into(), u, validate_shell(&payload["shell"])?], "comment" => vec!["account".into(), "comment".into(), u, validate_comment(&payload["comment"])?], "home" => vec!["account".into(), "home".into(), u, validate_home(&payload["home"])?, if payload["moveHome"] == true { "1" } else { "0" }.into()], "primary" | "group-add" | "group-remove" => vec!["account".into(), action.into(), u, validate_name(&payload["group"], "USERS_GROUP_INVALID")?], _ => vec!["account".into(), action.into(), u], }) } fn assert_mutable(user: &Value, action: &str) -> CoreResult<()> { match user["protection"].as_str() { Some("root") => Err(fail("USERS_PROTECTED_USER", "The root account is protected and cannot be changed here.")), Some("system") => Err(fail("USERS_PROTECTED_USER", "System accounts are protected and cannot be changed here.")), Some("session") => Err(fail( "USERS_PROTECTED_USER", if action == "password" { "Change the password of the active session account with passwd in the terminal; CH-J would otherwise invalidate the stored login." } else { "The account used by the active SSH session is protected against changes that could lock you out." }, )), _ => Ok(()), } } fn assert_not_last_admin(inventory: &Value, user: &Value, action: &str, payload: &Value) -> CoreResult<()> { if inventory["privilege"]["mode"] == "root" { return Ok(()); } if action == "shell" && is_login_shell(&js_string(&payload["shell"])) { return Ok(()); } let users = inventory["users"].as_array().cloned().unwrap_or_default(); let functional: Vec<&Value> = users.iter().filter(|c| c["uid"] != 0 && c["sudo"].as_str().is_some_and(|s| s.starts_with("full")) && c["expired"] != true && c["loginShell"] == true).collect(); if functional.iter().any(|c| c["username"] == user["username"]) && functional.iter().all(|c| c["username"] == user["username"]) { return Err(fail("USERS_LAST_ADMIN", "This is the last working administrator account; the change was refused.")); } Ok(()) } async fn create(&self, session_id: &str, payload: &Value, sudo: Option) -> CoreResult { let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?; let options = json!({ "sudoPassword": sudo, "privileged": true }); let inventory = self.inventory(session_id, &options).await?; if inventory.public["users"].as_array().is_some_and(|u| u.iter().any(|c| c["username"] == username.as_str())) { return Err(fail("USERS_USER_EXISTS", "The account already exists.")); } let shells = inventory.public["shells"].as_array().cloned().unwrap_or_default(); let fallback = if shells.iter().any(|s| s == "/bin/bash") { "/bin/bash" } else { "/bin/sh" }; let fallback_value = json!(fallback); let shell = validate_shell(if truthy(&payload["shell"]) { &payload["shell"] } else { &fallback_value })?; let home = if truthy(&payload["home"]) { validate_home(&payload["home"])? } else { String::new() }; let primary = if truthy(&payload["primaryGroup"]) { validate_name(&payload["primaryGroup"], "USERS_GROUP_INVALID")? } else { String::new() }; let mut group_list: Vec = Vec::new(); for group in payload["groups"].as_array().cloned().unwrap_or_default() { let name = validate_name(&group, "USERS_GROUP_INVALID")?; if !group_list.contains(&name) { group_list.push(name); } } if group_list.len() > 32 { return Err(fail("USERS_GROUP_INVALID", "Too many groups.")); } let empty = json!(""); let comment = validate_comment(if truthy(&payload["comment"]) { &payload["comment"] } else { &empty })?; let with_password = !matches!(&payload["password"], Value::Null) && payload["password"] != ""; let frames = if with_password { vec![Frame::text("SECRET", &validate_account_password(&payload["password"])?)] } else { vec![] }; let args = vec![ "create".into(), username.clone(), shell, home, if payload["createHome"] == false { "0" } else { "1" }.into(), primary, group_list.join(","), comment, if payload["admin"] == true { "1" } else { "0" }.into(), if with_password { "1" } else { "0" }.into(), ]; let auth = Auth { privileged: true, sudo_password: sudo }; let run = self.run(session_id, args, frames, &auth, "users.create", 60_000).await?; let outcome = parse_step_markers(&run.stdout); self.logger.warn("Remote account created.", json!({ "sessionId": session_id, "username": username, "result": outcome.result })); let refreshed = self.inventory(session_id, &options).await?; let created = refreshed.public["users"].as_array().and_then(|u| u.iter().find(|c| c["username"] == username.as_str())).cloned(); let mut warnings: Vec = Vec::new(); if created.is_none() { warnings.push("The server did not list the new account after creation.".into()); } if payload["admin"] == true && created.as_ref().is_some_and(|c| !c["sudo"].as_str().unwrap_or("").starts_with("full")) { warnings.push("sudo -l does not report full sudo rights for the new account.".into()); } Ok(json!({ "action": "create", "username": username, "result": if created.is_some() { outcome.result } else { "partial".into() }, "steps": outcome.steps, "warnings": warnings, "inventory": refreshed.public, "users": refreshed.public["users"] })) } /// Stages, verifies, reloads and confirms one change of the CH-J managed sshd drop-in. /// With `target_exists = false` (cleanup after deletion) the session account supplies the SSH context. async fn apply_policy_change(&self, session_id: &str, username: &str, policy: Option, auth: &Auth, credentials: &Value, target_exists: bool) -> CoreResult { let privilege = self.executor.probe(session_id, false).await?; let session = self.sessions.session_auth_info(session_id)?; let session_user = privilege["username"].as_str().unwrap_or("").to_string(); let detail = self.detail(session_id, if target_exists { username } else { &session_user }, &Auth { privileged: true, sudo_password: auth.sudo_password.clone() }).await?; let ssh = &detail.public["ssh"]; if target_exists && detail.public["account"]["system"] == true { return Err(fail("USERS_PROTECTED_USER", "SSH access of system accounts is not managed by CH-J.")); } if ssh["available"] != true { return Err(fail("USERS_SSHD_MISSING", "OpenSSH server was not found.")); } if ssh["layoutSupported"] != true { return Err(fail("USERS_SSH_LAYOUT_UNSUPPORTED", "sshd_config does not include sshd_config.d before its first Match block; CH-J does not edit the main configuration.")); } if ssh["syntaxOk"] == false { return Err(fail("USERS_SSH_CONFIG_ALREADY_INVALID", &format!("The current SSH configuration is invalid: {}", ssh["syntaxMessage"].as_str().unwrap_or("")))); } let managed = &detail.managed; if managed.state == "unsafe" || managed.state == "unreadable" { return Err(fail("USERS_SSH_MANAGED_FILE_UNSAFE", "The CH-J managed SSH file is not a readable regular file.")); } if !managed.consistent { return Err(fail("USERS_SSH_MANAGED_FILE_MODIFIED", &format!("{MANAGED_PATH} was edited manually; CH-J will not overwrite it."))); } let mut policies = managed.policies.clone(); match &policy { Some(p) if !p.is_default() => { policies.insert(username.to_string(), p.clone()); } _ => { policies.remove(username); } } let content = policy::generate_managed_config(&policies); let result_policy = policy.clone().unwrap_or_default().to_value(); if content == managed.content { return Ok(json!({ "changed": false, "applied": false, "policy": result_policy })); } let self_account = username == session_user; let method = if session["authMethod"] == "privateKey" { "publickey" } else { "password" }; let client_address = privilege["clientAddress"].as_str().unwrap_or("").to_string(); if client_address.parse::().is_err() { return Err(fail("USERS_SSH_CLIENT_UNKNOWN", "The client address of the SSH session is unknown, so lockout protection cannot be verified.")); } if self_account { let requirements = self.sessions.probe_login_requirements(session_id, credentials)?; if requirements["ready"] != true { return Err(fail("USERS_SSH_PROBE_CREDENTIALS_REQUIRED", &format!("Changing SSH access of the active session account requires a test login ({}).", requirements["missing"].as_str().unwrap_or("")))); } } let addresses = detail.addresses.clone(); let session_tuple = format!("{session_user}|{client_address}"); let mut tuples: Vec = if target_exists { vec![format!("{username}|{}", addresses.ipv4), format!("{username}|{}", addresses.ipv6), session_tuple.clone()] } else { vec![session_tuple.clone()] }; let mut seen = std::collections::HashSet::new(); tuples.retain(|t| seen.insert(t.clone())); let session_groups: Vec = if self_account { detail.groups.clone() } else { privilege["groups"].as_array().map(|g| g.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default() }; let mut args = vec!["ssh-apply".to_string(), managed.sha256.clone().unwrap_or_else(|| "absent".into()), SSH_WATCHDOG_SECONDS.to_string()]; args.extend(tuples); let options = self.options(args, vec![Frame::text("DATA", &content)], &Auth { privileged: true, sudo_password: auth.sudo_password.clone() }, "users.ssh-policy", (SSH_WATCHDOG_SECONDS + 60) * 1000); let sessions = self.sessions.clone(); let session_id_owned = session_id.to_string(); let username_owned = username.to_string(); let credentials_owned = credentials.clone(); let policy_for_check = policy.clone(); let interact = move |io: Interaction| async move { static STAGED: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_STAGED\r?\n").unwrap()); static ROLLED_BACK: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_ROLLED_BACK=[^\n]*\n").unwrap()); static RELOADED: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_RELOADED=([^\n]*)\n").unwrap()); static CONFIRMED: Lazy = Lazy::new(|| Regex::new(r"\x1eCHJ_CONFIRMED=([^\n]*)\n").unwrap()); let staged = io.wait_for(&STAGED, Duration::from_secs(60)).await?; let before = parse_effective_blocks(&staged.before, "BEFORE"); let after = parse_effective_blocks(&staged.before, "AFTER"); let mut problems: Vec = Vec::new(); if let (Some(policy), true) = (&policy_for_check, target_exists) { for (family, label) in [("ipv4", "IPv4"), ("ipv6", "IPv6")] { match after.get(&format!("{username_owned}|{}", addresses.get(family))) { Some(effective) if effective.errors.is_empty() => problems.extend(policy::verify_policy_effect(policy, &username_owned, effective, label, &Default::default())), _ => problems.push(format!("{label}: sshd -T did not return a usable result")), } } } let context = AccessContext { username: &session_user, groups: &session_groups, address: &client_address, method: Some(method), keyboard_interactive: true }; let lockout = match after.get(&session_tuple) { Some(after_session) if after_session.errors.is_empty() => { if self_account { let evaluation = policy::evaluate_access(after_session, &AccessContext { username: &username_owned, groups: &session_groups, address: &client_address, method: Some(method), keyboard_interactive: true }); (evaluation.method_allowed != Some(true)).then(|| format!("The active session ({method} from {client_address}) would no longer be allowed to log in.")) } else { // DenyUsers lines of other accounts appear in every sshd -T dump, so compare the evaluated result. match before.get(&session_tuple) { Some(before_session) if comparable_access(before_session, &context) == comparable_access(after_session, &context) => None, _ => Some("The change would alter the effective SSH access of the active session account.".to_string()), } } } _ => Some("sshd -T could not evaluate the active session account.".to_string()), }; if !problems.is_empty() || lockout.is_some() { io.send("ROLLBACK"); io.wait_for(&ROLLED_BACK, Duration::from_secs(30)).await?; return Ok(json!({ "rolledBack": true, "problems": problems, "lockout": lockout })); } io.send("COMMIT"); let reloaded = io.wait_for(&RELOADED, Duration::from_secs(60)).await?; if self_account { if let Err(error) = sessions.probe_login(&session_id_owned, &credentials_owned).await { io.send("ROLLBACK"); io.wait_for(&ROLLED_BACK, Duration::from_secs(60)).await?; return Ok(json!({ "rolledBack": true, "problems": [], "lockout": null, "probeError": error.message })); } } io.send("CONFIRM"); let confirmed = io.wait_for(&CONFIRMED, Duration::from_secs(30)).await?; let group = |m: &crate::core::ssh::privileged::Matched| m.groups.get(1).cloned().flatten().unwrap_or_default(); Ok(json!({ "rolledBack": false, "reload": clean_text(&group(&reloaded), 200), "backup": clean_text(&group(&confirmed), 300), "probed": self_account })) }; let (_, outcome) = self.executor.run_interactive(session_id, options, Some(interact)).await?; let outcome = outcome.unwrap_or_else(|| json!({ "rolledBack": true, "problems": ["no result"], "lockout": null })); if outcome["rolledBack"] == true { let reason = if !outcome["lockout"].is_null() { "lockout" } else if outcome.get("probeError").is_some() { "probe" } else { "not-effective" }; self.logger.warn("SSH policy change rolled back.", json!({ "sessionId": session_id, "username": username, "reason": reason })); if let Some(lockout) = outcome["lockout"].as_str() { return Err(fail("USERS_SSH_LOCKOUT_PREVENTED", &format!("{lockout} The previous configuration was restored."))); } if let Some(probe) = outcome.get("probeError").and_then(Value::as_str) { return Err(fail("USERS_SSH_PROBE_FAILED", &format!("{probe} The previous SSH configuration was restored and reloaded."))); } let problems: Vec = outcome["problems"].as_array().map(|p| p.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); return Err(fail("USERS_SSH_POLICY_NOT_EFFECTIVE", &format!("Existing sshd rules take precedence ({}). The previous configuration was restored.", problems.join("; ")))); } self.logger.warn("SSH policy applied and confirmed.", json!({ "sessionId": session_id, "username": username, "probed": outcome["probed"] })); Ok(json!({ "changed": true, "applied": true, "policy": result_policy, "reload": outcome["reload"], "backup": outcome["backup"], "probed": outcome["probed"] })) } } /// authorized_keys file reported by `remoteUsers.sh detail`. pub struct KeyFile { pub path: String, pub spec: String, pub kind: String, pub meta: String, pub dir_meta: String, pub manageable: bool, pub reason: Option<&'static str>, pub entries: Vec, pub raw: Vec, } impl KeyFile { pub fn public(&self) -> Value { json!({ "path": self.path, "spec": self.spec, "type": self.kind, "meta": self.meta, "dirMeta": self.dir_meta, "manageable": self.manageable, "reason": self.reason, "entries": self.entries.iter().map(keys::KeyEntry::public).collect::>() }) } } static TRAVERSAL: Lazy = Lazy::new(|| Regex::new(r"/\.\.?(/|$)").unwrap()); fn parse_key_files(text: &str, home: &str) -> Vec { let mut files = Vec::new(); for block in text.split("\x1eCHJ_KEYFILE=").skip(1) { let mut block_lines = block.split('\n'); let first = block_lines.next().unwrap_or(""); let path_line = first.strip_suffix('\r').unwrap_or(first); let values = key_values(&block_lines.collect::>().join("\n")); let path = clean_text(path_line, 300); let raw = values.get("data").map(|d| B64.decode(d).unwrap_or_default()).unwrap_or_default(); let inside_home = !home.is_empty() && home != "/" && path.starts_with(&format!("{home}/")) && !TRAVERSAL.is_match(&path); let kind = values.get("type").cloned().unwrap_or_else(|| "unknown".into()); let reason: Option<&'static str> = if !inside_home { Some("outside-home") } else { match kind.as_str() { "symlink" => Some("symlink"), "other" => Some("not-regular"), "unreadable" => Some("unreadable"), "unsupported" => Some("unsupported-token"), _ if values.get("tooLarge").map(String::as_str) == Some("1") => Some("too-large"), "file" | "absent" | "absent-dir" => None, _ => Some("unknown"), } }; files.push(KeyFile { path, spec: clean_text(values.get("spec").map(String::as_str).unwrap_or(""), 200), entries: if kind == "file" { keys::parse_authorized_keys(&raw) } else { Vec::new() }, meta: clean_text(values.get("meta").map(String::as_str).unwrap_or(""), 120), dir_meta: clean_text(values.get("dirMeta").map(String::as_str).unwrap_or(""), 120), manageable: reason.is_none(), reason, kind, raw, }); } files } #[cfg(test)] mod tests { use super::*; #[test] fn parsers() { let out = parse_step_markers("x\x1eCHJ_STEP=useradd|ok|created\n\x1eCHJ_SUDO=full-all\n\x1eCHJ_RESULT=ok\n"); assert_eq!(out.result, "ok"); assert_eq!(out.sudo.as_deref(), Some("full-all")); assert_eq!(out.steps[0]["message"], "created"); let blocks = parse_effective_blocks("\x1eCHJ_BEFORE=bob|1.2.3.4\npasswordauthentication yes\n\x1eCHJ_AFTER=bob|1.2.3.4\npasswordauthentication no\n", "AFTER"); assert_eq!(blocks["bob|1.2.3.4"].get("passwordauthentication"), Some("no")); assert!(validate_home(&json!("/etc/x")).is_err()); assert_eq!(validate_home(&json!("/var/lib/app/")).unwrap(), "/var/lib/app"); assert!(validate_home(&json!("/var")).is_err()); assert!(validate_shell(&json!("/bin/../bin/sh")).is_err()); assert!(validate_comment(&json!("a:b")).is_err()); let files = parse_key_files("\x1eCHJ_KEYFILE=/home/bob/.ssh/authorized_keys\ntype=absent\nspec=.ssh/authorized_keys\n\x1eCHJ_KEYFILE=/etc/ssh/keys\ntype=file\n", "/home/bob"); assert!(files[0].manageable, "{:?} {:?} {}", files[0].reason, files[0].path, files[0].kind); assert_eq!(files[1].reason, Some("outside-home")); } } #[cfg(test)] #[path = "../../../tests/support/users_safety.rs"] mod safety_tests;