CH-J Toolsgerenciamento de servidor sobre SSH
E-mail
Fonte publicada

CH-J Tools

Navegue por diretórios e arquivos para uma versão específica do aplicativo.

Transferir fonte ZIP
CH-J / Server Manager 872 arquivos
core-20261030T220507Z-61e4558d889f Adicionado: · Construído:
linux.rs Adicionado: Data não registada 56,3 KB · 979 linhas
Código Transferir ficheiro
1//! Linux user administration (port of `users/userAdminService.js`, Linux part).
2//!
3//! All remote work runs the fixed `remoteUsers.sh` through the PrivilegedExecutor. Account
4//! changes are refused for root, system accounts and the active session account; changes that
5//! could remove the last working administrator are refused. SSH policy changes are staged,
6//! verified with `sshd -T`, reloaded, confirmed (with a fresh test login for the session
7//! account) and rolled back automatically otherwise.
9use super::common::*;
10use super::keys;
11use super::policy::{self, AccessContext, Effective, Policies, Policy, MANAGED_PATH};
12use crate::core::error::CoreResult;
13use crate::core::logging::Logger;
14use crate::core::ssh::privileged::{Frame, Interaction, PrivilegedExecutor, RunOptions, RunResult};
15use crate::core::ssh::session::SessionManager;
16use base64::engine::general_purpose::STANDARD as B64;
17use base64::Engine;
18use once_cell::sync::Lazy;
19use regex::Regex;
20use serde_json::{json, Map, Value};
21use std::collections::BTreeMap;
22use std::sync::Arc;
23use std::time::Duration;
25pub const REMOTE_SCRIPT: &str = include_str!("../../../resources/remote/remoteUsers.sh");
26const SSH_WATCHDOG_SECONDS: u64 = 120;
27const FORBIDDEN_HOMES: [&str; 14] = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"];
28const ACCOUNT_ACTIONS: [&str; 13] = ["lock", "unlock", "expire", "unexpire", "password", "shell", "comment", "home", "primary", "group-add", "group-remove", "sudo-grant", "sudo-revoke"];
29/// Actions that can remove the last working administrator.
30const ADMIN_REDUCING: [&str; 5] = ["delete", "lock", "expire", "sudo-revoke", "shell"];
32fn alias(action: &str) -> &str {
33 match action {
34 "grantAdmin" => "sudo-grant",
35 "revokeAdmin" => "sudo-revoke",
36 "setPassword" => "password",
37 "setShell" => "shell",
38 "setComment" => "comment",
39 "setHome" => "home",
40 "setPrimaryGroup" => "primary",
41 "addGroup" => "group-add",
42 "removeGroup" => "group-remove",
43 other => other,
44 }
47pub fn validate_name(value: &Value, code: &str) -> CoreResult<String> {
48 let name = js_string(value).trim().to_string();
49 if !policy::is_policy_name(&name) {
50 return Err(fail(code, "Names must start with a lowercase letter or underscore and contain only a-z, 0-9, _ or - (max. 32)."));
51 }
52 Ok(name)
55static SHELL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+-]{1,127}$").unwrap());
56static HOME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/@+-]{1,254}$").unwrap());
57static HOME_BAD: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)|//").unwrap());
59pub fn validate_shell(value: &Value) -> CoreResult<String> {
60 let shell = js_string(value).trim().to_string();
61 if !SHELL.is_match(&shell) || shell.contains("/../") || shell.ends_with("/..") {
62 return Err(fail("USERS_SHELL_INVALID", "Invalid shell path."));
63 }
64 Ok(shell)
67pub fn validate_home(value: &Value) -> CoreResult<String> {
68 let home = js_string(value).trim().trim_end_matches('/').to_string();
69 if !HOME.is_match(&home) || HOME_BAD.is_match(&home) {
70 return Err(fail("USERS_HOME_INVALID", "The home directory must be an absolute path without special characters."));
71 }
72 if FORBIDDEN_HOMES.iter().any(|root| home == *root || (*root != "/var" && home.starts_with(&format!("{root}/")))) {
73 return Err(fail("USERS_HOME_INVALID", "System directories cannot be used as a home directory."));
74 }
75 Ok(home)
78pub fn validate_comment(value: &Value) -> CoreResult<String> {
79 let comment = js_string(value).trim().to_string();
80 if comment.encode_utf16().count() > 100 || comment.chars().any(|c| c == ':' || c == '\\' || (c as u32) < 0x20 || c as u32 == 0x7f) {
81 return Err(fail("USERS_COMMENT_INVALID", "The full name may contain up to 100 characters without ':' or control characters."));
82 }
83 Ok(comment)
86pub fn validate_account_password(value: &Value) -> CoreResult<String> {
87 let password = js_string(value);
88 if password.is_empty() || password.encode_utf16().count() > 256 || password.contains(['\0', '\r', '\n']) {
89 return Err(fail("USERS_PASSWORD_INVALID", "The password must contain 1-256 characters and no line breaks."));
90 }
91 Ok(password)
94struct Managed {
95 state: &'static str,
96 sha256: Option<String>,
97 content: String,
98 policies: Policies,
99 consistent: bool,
102fn parse_managed_section(text: &str) -> Managed {
103 let values = key_values(text);
104 let empty = |state, consistent| Managed { state, sha256: None, content: String::new(), policies: Policies::new(), consistent };
105 if values.get("unsafe").map(String::as_str) == Some("1") {
106 return empty("unsafe", false);
107 }
108 if values.get("unreadable").map(String::as_str) == Some("1") {
109 return empty("unreadable", false);
110 }
111 match values.get("sha256").map(String::as_str) {
112 None | Some("absent") => Managed { state: "absent", sha256: Some("absent".into()), content: String::new(), policies: Policies::new(), consistent: true },
113 Some(sha) => {
114 let content = String::from_utf8_lossy(&B64.decode(values.get("data").map(String::as_str).unwrap_or("")).unwrap_or_default()).to_string();
115 let parsed = policy::parse_managed_config(&content);
116 Managed { state: "present", sha256: Some(sha.to_string()), content, policies: parsed.policies, consistent: parsed.consistent }
117 }
118 }
121struct Passwd {
122 username: String,
123 uid: Option<i64>,
124 gid: i64,
125 comment: String,
126 home: String,
127 shell: String,
130fn parse_passwd(line: &str) -> Passwd {
131 let parts: Vec<&str> = line.split(':').collect();
132 let field = |i: usize| parts.get(i).copied().unwrap_or("");
133 Passwd {
134 username: field(0).to_string(),
135 uid: js_number(field(2)).filter(|n| n.fract() == 0.0).map(|n| n as i64),
136 gid: js_number(field(3)).unwrap_or(f64::NAN) as i64,
137 comment: clean_text(field(4).split(',').next().unwrap_or(""), 100),
138 home: clean_text(field(5), 255),
139 shell: clean_text(field(6), 255),
140 }
143impl Passwd {
144 fn value(&self) -> Map<String, Value> {
145 let mut map = Map::new();
146 map.insert("username".into(), json!(self.username));
147 map.insert("uid".into(), json!(self.uid));
148 map.insert("gid".into(), json!(self.gid));
149 map.insert("comment".into(), json!(self.comment));
150 map.insert("home".into(), json!(self.home));
151 map.insert("shell".into(), json!(self.shell));
152 map
153 }
156fn parse_shadow_state(value: &str) -> Map<String, Value> {
157 let parts: Vec<&str> = value.split(':').collect();
158 let state = parts.first().copied().unwrap_or("unknown");
159 let last = parts.get(1).copied().unwrap_or("");
160 let expire = parts.get(2).copied().unwrap_or("");
161 let expire_days = if expire.is_empty() { None } else { js_number(expire).filter(|n| n.is_finite()) };
162 let mut map = Map::new();
163 map.insert("password".into(), json!(if ["set", "locked", "none", "empty"].contains(&state) { state } else { "unknown" }));
164 map.insert("lastChangeDays".into(), if last.is_empty() { Value::Null } else { js_number(last).map(Value::from).unwrap_or(Value::Null) });
165 map.insert("expireDays".into(), expire_days.map(Value::from).unwrap_or(Value::Null));
166 map.insert("expired".into(), json!(expire_days.is_some_and(|d| d * 86_400_000.0 <= chrono::Utc::now().timestamp_millis() as f64)));
167 map
170fn unknown_shadow(expired: Value) -> Map<String, Value> {
171 let mut map = Map::new();
172 map.insert("password".into(), json!("unknown"));
173 map.insert("lastChangeDays".into(), Value::Null);
174 map.insert("expireDays".into(), Value::Null);
175 map.insert("expired".into(), expired);
176 map
179pub fn is_login_shell(shell: &str) -> bool {
180 !shell.is_empty() && !shell.ends_with("/nologin") && !shell.ends_with("/false")
183#[derive(Debug, Clone, Default)]
184pub struct StepOutcome {
185 pub steps: Vec<Value>,
186 pub result: String,
187 pub sudo: Option<String>,
190pub fn parse_step_markers(stdout: &str) -> StepOutcome {
191 static STEP: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n").unwrap());
192 static RESULT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_RESULT=([a-z]+)").unwrap());
193 static SUDO: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_SUDO=([a-z-]+)").unwrap());
194 StepOutcome {
195 steps: STEP.captures_iter(stdout).map(|c| json!({ "step": &c[1], "status": &c[2], "message": clean_text(&c[3], 300) })).collect(),
196 result: RESULT.captures(stdout).map(|c| c[1].to_string()).unwrap_or_else(|| "unknown".into()),
197 sudo: SUDO.captures(stdout).map(|c| c[1].to_string()),
198 }
201/// `\x1eCHJ_<marker>=<key>\n<body>` blocks up to the next `\x1eCHJ_` or the end.
202pub fn parse_effective_blocks(text: &str, marker: &str) -> BTreeMap<String, Effective> {
203 let tag = format!("\x1eCHJ_{marker}=");
204 let mut blocks = BTreeMap::new();
205 let mut rest = text;
206 while let Some(start) = rest.find(&tag) {
207 let after = &rest[start + tag.len()..];
208 let Some(newline) = after.find('\n') else { break };
209 let key = &after[..newline];
210 let body_start = &after[newline + 1..];
211 let end = body_start.find("\x1eCHJ_").unwrap_or(body_start.len());
212 if !key.is_empty() {
213 blocks.insert(key.to_string(), policy::parse_effective(&body_start[..end]));
214 }
215 rest = &body_start[end..];
216 }
217 blocks
220const ACCESS_LIST_KEYS: [&str; 4] = ["denyusers", "allowusers", "denygroups", "allowgroups"];
222fn comparable_access(effective: &Effective, context: &AccessContext) -> String {
223 let evaluation = policy::evaluate_access(effective, context);
224 let settings: Vec<(&String, &Value)> = effective.values.iter().filter(|(k, _)| !ACCESS_LIST_KEYS.contains(&k.as_str())).collect();
225 serde_json::to_string(&json!({ "allowed": evaluation.allowed_value(), "methods": evaluation.methods, "settings": settings })).unwrap_or_default()
228/// Inventory with internal fields kept out of IPC.
229pub struct Inventory {
230 pub public: Value,
231 pub policies: Policies,
234/// Detail with internal fields kept out of IPC.
235pub struct Detail {
236 pub public: Value,
237 pub key_files: Vec<KeyFile>,
238 managed: Managed,
239 addresses: Addresses,
240 groups: Vec<String>,
243pub struct LinuxUsers {
244 pub sessions: Arc<SessionManager>,
245 pub executor: Arc<PrivilegedExecutor>,
246 pub logger: Arc<Logger>,
249pub struct Auth {
250 pub privileged: bool,
251 pub sudo_password: Option<String>,
254fn sudo_password(payload: &Value) -> Option<String> {
255 match payload.get("sudoPassword") {
256 Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
257 _ => None,
258 }
261impl LinuxUsers {
262 async fn run(&self, session_id: &str, args: Vec<String>, frames: Vec<Frame>, auth: &Auth, purpose: &str, timeout_ms: u64) -> CoreResult<RunResult> {
263 self.executor.run(session_id, self.options(args, frames, auth, purpose, timeout_ms)).await
264 }
266 fn options(&self, args: Vec<String>, frames: Vec<Frame>, auth: &Auth, purpose: &str, timeout_ms: u64) -> RunOptions {
267 RunOptions {
268 script: REMOTE_SCRIPT.to_string(),
269 args,
270 frames,
271 privileged: auth.privileged,
272 sudo_password: auth.sudo_password.clone(),
273 purpose: purpose.to_string(),
274 error_prefix: "USERS_".into(),
275 timeout: Duration::from_millis(timeout_ms),
276 ..Default::default()
277 }
278 }
280 fn wants_privilege(privilege: &Value, options: &Value) -> bool {
281 sudo_password(options).is_some() || options["privileged"] == true || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or(""))
282 }
284 pub async fn privileges(&self, session_id: &str, refresh: bool) -> CoreResult<Value> {
285 let probe = self.executor.probe(session_id, refresh).await?;
286 let mut out = Map::new();
287 out.insert("platform".into(), json!("linux"));
288 if let Some(map) = probe.as_object() {
289 for (k, v) in map {
290 out.insert(k.clone(), v.clone());
291 }
292 }
293 Ok(Value::Object(out))
294 }
296 /// Lists accounts. Without privileges only public account data is read; shadow state and the
297 /// effective sudo rights need root and are fetched automatically when no password is required.
298 pub async fn inventory(&self, session_id: &str, options: &Value) -> CoreResult<Inventory> {
299 let privilege = self.executor.probe(session_id, false).await?;
300 let privileged = Self::wants_privilege(&privilege, options);
301 let run = self.run(session_id, vec!["inventory".into()], vec![], &Auth { privileged, sudo_password: sudo_password(options) }, "users.inventory", 90_000).await?;
302 let privilege = self.executor.probe(session_id, false).await?;
303 Ok(self.parse_inventory(session_id, &run.stdout, privileged, privilege))
304 }
306 fn parse_inventory(&self, session_id: &str, stdout: &str, privileged: bool, privilege: Value) -> Inventory {
307 let sections = split_sections(stdout);
308 let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or("");
309 let meta = key_values(section("meta"));
310 let mut tools: Vec<String> = lines(section("meta")).into_iter().filter_map(|l| l.strip_prefix("tool=").map(str::to_string)).collect();
311 tools.sort();
312 tools.dedup();
313 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;
314 let groups: Vec<(String, i64, Vec<String>)> = lines(section("group"))
315 .into_iter()
316 .map(|line| {
317 let parts: Vec<&str> = line.split(':').collect();
318 let members = parts.get(3).copied().unwrap_or("").split(',').filter(|s| !s.is_empty()).map(str::to_string).collect();
319 (parts.first().copied().unwrap_or("").to_string(), js_number(parts.get(2).copied().unwrap_or("")).unwrap_or(f64::NAN) as i64, members)
320 })
321 .filter(|(name, _, _)| !name.is_empty())
322 .collect();
323 let group_by_gid: BTreeMap<i64, String> = groups.iter().map(|(n, g, _)| (*g, n.clone())).collect();
324 let shadow: BTreeMap<String, Map<String, Value>> = lines(section("shadow"))
325 .into_iter()
326 .map(|line| {
327 let (name, rest) = line.split_once(':').unwrap_or((line, ""));
328 (name.to_string(), parse_shadow_state(rest))
329 })
330 .collect();
331 let sudo: BTreeMap<String, String> = 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();
332 let admin_groups: Vec<String> = lines(section("admingroups")).into_iter().map(str::to_string).collect();
333 let managed = parse_managed_section(section("managed"));
334 let session_user = privilege["username"].as_str().unwrap_or("").to_string();
335 let mut users: Vec<Value> = lines(section("passwd"))
336 .into_iter()
337 .map(parse_passwd)
338 .filter(|u| !u.username.is_empty() && u.uid.is_some())
339 .map(|user| {
340 let uid = user.uid.unwrap_or(0);
341 let state = shadow.get(&user.username).cloned().unwrap_or_else(|| unknown_shadow(Value::Null));
342 let supplementary: Vec<String> = groups.iter().filter(|(_, _, m)| m.contains(&user.username)).map(|(n, _, _)| n.clone()).collect();
343 let primary = group_by_gid.get(&user.gid).cloned();
344 let member_admin: Vec<&str> = ["sudo", "wheel", "admin"].into_iter().filter(|n| supplementary.iter().any(|s| s == n) || primary.as_deref() == Some(*n)).collect();
345 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() };
346 let system = uid != 0 && (uid < uid_min || uid == 65534);
347 let ssh_policy = managed.policies.get(&user.username).map(Policy::to_value).unwrap_or(Value::Null);
348 let mut map = user.value();
349 map.insert("displayName".into(), json!(user.comment));
350 map.insert("primaryGroup".into(), json!(primary.clone().unwrap_or_else(|| user.gid.to_string())));
351 map.insert("groups".into(), json!(supplementary));
352 map.insert("system".into(), json!(system));
353 map.insert("loginShell".into(), json!(is_login_shell(&user.shell)));
354 for (k, v) in &state {
355 map.insert(k.clone(), v.clone());
356 }
357 map.insert("locked".into(), json!(state.get("password").and_then(Value::as_str) == Some("locked")));
358 map.insert("passwordState".into(), state.get("password").cloned().unwrap_or(json!("unknown")));
359 map.insert("sudo".into(), json!(sudo_state));
360 map.insert("adminGroups".into(), json!(member_admin));
361 map.insert("admin".into(), json!(uid == 0 || if sudo_state == "unknown" { !member_admin.is_empty() } else { sudo_state.starts_with("full") }));
362 map.insert("sshPolicy".into(), ssh_policy);
363 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 });
364 Value::Object(map)
365 })
366 .collect();
367 users.sort_by(|a, b| {
368 (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("")))
369 });
370 let shells: Vec<String> = lines(section("shells")).into_iter().filter(|s| SHELL.is_match(s)).map(str::to_string).collect();
371 let public = json!({
372 "platform": "linux",
373 "sessionId": session_id,
374 "privileged": privileged,
375 "privilege": privilege,
376 "server": {
377 "os": clean_text(meta.get("os").map(String::as_str).unwrap_or(""), 120),
378 "osId": clean_text(meta.get("osId").map(String::as_str).unwrap_or(""), 40),
379 "uidMin": uid_min,
380 "tools": tools,
381 "sshLayout": meta.get("layout").cloned().unwrap_or_else(|| "unknown".into()),
382 "managedPath": MANAGED_PATH
383 },
384 "shells": shells,
385 "groups": groups.iter().map(|(n, g, m)| json!({ "name": n, "gid": g, "members": m })).collect::<Vec<_>>(),
386 "adminGroups": admin_groups,
387 "managedPolicy": { "state": managed.state, "consistent": managed.consistent, "users": managed.policies.keys().collect::<Vec<_>>() },
388 "users": users,
389 "collectedAt": now_iso()
390 });
391 Inventory { public, policies: managed.policies }
392 }
394 pub async fn list_compat(&self, session_id: &str) -> CoreResult<Value> {
395 let inventory = self.inventory(session_id, &json!({})).await?;
396 Ok(Value::Array(inventory.public["users"].as_array().cloned().unwrap_or_default().into_iter().filter(|u| u["uid"] == 0 || u["system"] != true).collect()))
397 }
399 pub async fn detail_public(&self, session_id: &str, options: &Value) -> CoreResult<Value> {
400 let username = validate_name(&options["username"], "USERS_NAME_INVALID")?;
401 let privilege = self.executor.probe(session_id, false).await?;
402 let privileged = Self::wants_privilege(&privilege, options);
403 Ok(self.detail(session_id, &username, &Auth { privileged, sudo_password: sudo_password(options) }).await?.public)
404 }
406 pub async fn detail(&self, session_id: &str, username: &str, auth: &Auth) -> CoreResult<Detail> {
407 let privilege = self.executor.probe(session_id, false).await?;
408 let session = self.sessions.session_auth_info(session_id)?;
409 let addresses = family_addresses(privilege["clientAddress"].as_str().unwrap_or(""));
410 let run = self.run(session_id, vec!["detail".into(), username.into(), addresses.ipv4.clone(), addresses.ipv6.clone()], vec![], auth, "users.detail", 60_000).await?;
411 let sections = split_sections(&run.stdout);
412 let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or("");
413 let account_values = key_values(section("account"));
414 let account = parse_passwd(account_values.get("passwd").map(String::as_str).unwrap_or(""));
415 if account.username != username {
416 return Err(fail("USERS_PROTOCOL_ERROR", "The server returned an unexpected account."));
417 }
418 let groups: Vec<String> = account_values.get("groups").map(String::as_str).unwrap_or("").split(',').filter(|s| !s.is_empty()).map(str::to_string).collect();
419 let state = account_values.get("shadow").filter(|s| !s.is_empty()).map(|s| parse_shadow_state(s)).unwrap_or_else(|| unknown_shadow(Value::Null));
420 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;
421 let uid = account.uid.unwrap_or(0);
422 let system = uid != 0 && (uid < uid_min || uid == 65534);
423 let ssh_values = key_values(section("ssh"));
424 let managed = parse_managed_section(section("managed"));
425 let policy = managed.policies.get(username).cloned().unwrap_or_default();
426 let mut effective = Map::new();
427 let mut warnings: Vec<String> = Vec::new();
428 let mut listener: Option<Value> = None;
429 let mut keys_command: Option<String> = None;
430 let mut processed = false;
431 for (family, label) in [("ipv4", "effective4"), ("ipv6", "effective6")] {
432 let Some(text) = sections.get(label) else { continue };
433 processed = true;
434 let parsed = policy::parse_effective(text);
435 let address = addresses.get(family);
436 let evaluation = policy::evaluate_access(&parsed, &AccessContext { username, groups: &groups, address: &address, method: None, keyboard_interactive: true });
437 effective.insert(
438 family.into(),
439 json!({
440 "address": address,
441 "sample": addresses.sample(family),
442 "passwordAuthentication": parsed.get("passwordauthentication").unwrap_or("unknown"),
443 "kbdInteractiveAuthentication": parsed.get("kbdinteractiveauthentication").or(parsed.get("challengeresponseauthentication")).unwrap_or("unknown"),
444 "pubkeyAuthentication": parsed.get("pubkeyauthentication").unwrap_or("unknown"),
445 "authenticationMethods": parsed.get("authenticationmethods").unwrap_or("any"),
446 "permitRootLogin": parsed.get("permitrootlogin").unwrap_or("unknown"),
447 "allowed": evaluation.allowed_value(),
448 "reasons": evaluation.reasons,
449 "methods": evaluation.methods,
450 "errors": parsed.errors.iter().take(5).collect::<Vec<_>>()
451 }),
452 );
453 if listener.is_none() {
454 listener = Some(policy::listener_families(&parsed));
455 }
456 if keys_command.is_none() {
457 if let Some(command) = parsed.get("authorizedkeyscommand").filter(|c| *c != "none") {
458 keys_command = Some(clean_text(command, 200));
459 }
460 }
461 }
462 if let Some(l) = &listener {
463 effective.insert("listener".into(), l.clone());
464 if l["ipv6"] == false {
465 warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy.".into());
466 }
467 if l["ipv4"] == false {
468 warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy.".into());
469 }
470 }
471 if processed {
472 effective.insert("authorizedKeysCommand".into(), keys_command.clone().map(Value::from).unwrap_or(Value::Null));
473 }
474 if keys_command.is_some() {
475 warnings.push("AuthorizedKeysCommand is configured; keys may also come from an external source that CH-J does not manage.".into());
476 }
477 if managed.state == "present" && !managed.consistent {
478 warnings.push(format!("{MANAGED_PATH} was edited manually; CH-J will not overwrite it."));
479 }
480 let key_files = parse_key_files(section("keys"), &account.home);
481 let self_account = username == privilege["username"].as_str().unwrap_or("");
482 let mut account_map = account.value();
483 account_map.insert("primaryGroup".into(), json!(groups.first().cloned().unwrap_or_else(|| account.gid.to_string())));
484 account_map.insert("groups".into(), json!(groups.iter().skip(1).collect::<Vec<_>>()));
485 account_map.insert("system".into(), json!(system));
486 account_map.insert("loginShell".into(), json!(is_login_shell(&account.shell)));
487 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));
488 account_map.insert("homeMeta".into(), json!(clean_text(account_values.get("homeMeta").map(String::as_str).unwrap_or(""), 120)));
489 for (k, v) in &state {
490 account_map.insert(k.clone(), v.clone());
491 }
492 account_map.insert("sudo".into(), json!(account_values.get("sudo").filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| "unknown".into())));
493 account_map.insert("protection".into(), if uid == 0 { json!("root") } else if system { json!("system") } else if self_account { json!("session") } else { Value::Null });
494 let ssh_layout = ssh_values.get("layout").cloned().unwrap_or_else(|| "unknown".into());
495 let public = json!({
496 "platform": "linux",
497 "sessionId": session_id,
498 "username": username,
499 "privileged": run.privileged && auth.privileged,
500 "self": self_account,
501 "session": { "authMethod": session["authMethod"], "username": privilege["username"], "clientAddress": privilege["clientAddress"] },
502 "account": Value::Object(account_map),
503 "ssh": {
504 "available": ssh_values.get("sshd").is_some_and(|v| v != "missing"),
505 "version": clean_text(ssh_values.get("version").map(String::as_str).unwrap_or(""), 160),
506 "layout": ssh_layout,
507 "layoutSupported": ssh_layout == "ok",
508 "service": clean_text(ssh_values.get("service").map(String::as_str).unwrap_or(""), 120),
509 "syntaxOk": ssh_values.get("syntax").map(|s| json!(s == "0")).unwrap_or(Value::Null),
510 "syntaxMessage": clean_text(ssh_values.get("syntaxMessage").map(String::as_str).unwrap_or(""), 400),
511 "managed": { "state": managed.state, "consistent": managed.consistent, "path": MANAGED_PATH },
512 "policy": policy.to_value(),
513 "effective": Value::Object(effective),
514 "warnings": warnings
515 },
516 "keys": { "files": key_files.iter().map(KeyFile::public).collect::<Vec<_>>(), "home": account.home },
517 "collectedAt": now_iso()
518 });
519 Ok(Detail { public, key_files, managed, addresses, groups })
520 }
522 pub async fn manage(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
523 let requested = js_string(&payload["action"]);
524 let action = alias(&requested).to_string();
525 let sudo = sudo_password(payload);
526 if action == "create" {
527 return self.create(session_id, payload, sudo).await;
528 }
529 if action != "delete" && !ACCOUNT_ACTIONS.contains(&action.as_str()) {
530 return Err(fail("USERS_ACTION_INVALID", "Unsupported user-management action."));
531 }
532 let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?;
533 let args = Self::account_args(&action, &username, payload)?;
534 let options = json!({ "sudoPassword": sudo, "privileged": true });
535 let before = self.inventory(session_id, &options).await?;
536 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."))?;
537 Self::assert_mutable(&user, &action)?;
538 if ADMIN_REDUCING.contains(&action.as_str()) {
539 Self::assert_not_last_admin(&before.public, &user, &action, payload)?;
540 }
541 if action == "group-remove" {
542 let group = before.public["groups"].as_array().and_then(|g| g.iter().find(|g| g["name"] == args[3].as_str())).cloned();
543 if group.is_some_and(|g| g["gid"] == user["gid"]) {
544 return Err(fail("USERS_GROUP_PRIMARY", "The primary group cannot be removed; change the primary group first."));
545 }
546 }
547 let frames = if action == "password" { vec![Frame::text("SECRET", &validate_account_password(&payload["password"])?)] } else { vec![] };
548 let auth = Auth { privileged: true, sudo_password: sudo.clone() };
549 let run = self.run(session_id, args, frames, &auth, &format!("users.{action}"), 60_000).await?;
550 let mut outcome = parse_step_markers(&run.stdout);
551 let mut warnings: Vec<String> = Vec::new();
552 if action == "delete" && before.policies.contains_key(&username) {
553 match self.apply_policy_change(session_id, &username, None, &auth, &json!({}), false).await {
554 Ok(_) => outcome.steps.push(json!({ "step": "ssh-policy", "status": "ok", "message": "CH-J SSH rules for the account were removed." })),
555 Err(error) => {
556 warnings.push(format!("CH-J SSH rules for the deleted account remain: {}", error.message));
557 outcome.result = "partial".into();
558 }
559 }
560 }
561 if let Some(sudo_state) = &outcome.sudo {
562 if action == "sudo-grant" && !sudo_state.starts_with("full") {
563 warnings.push("The account was added to the administrator group, but sudo -l does not report full sudo rights. sudoers was not changed.".into());
564 }
565 if action == "sudo-revoke" && sudo_state != "none" && sudo_state != "unavailable" {
566 warnings.push(format!("sudo rights remain ({sudo_state}) because they are granted by another sudoers rule. CH-J does not edit sudoers."));
567 }
568 }
569 self.logger.warn("Remote user-management action completed.", json!({ "sessionId": session_id, "action": action, "username": username, "result": outcome.result }));
570 let inventory = self.inventory(session_id, &options).await?;
571 Ok(json!({ "action": requested, "username": username, "result": outcome.result, "steps": outcome.steps, "warnings": warnings, "sudo": outcome.sudo, "inventory": inventory.public, "users": inventory.public["users"] }))
572 }
574 pub async fn keys(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
575 let action = js_string(&payload["action"]);
576 if action != "add" && action != "remove" {
577 return Err(fail("USERS_ACTION_INVALID", "Unsupported key action."));
578 }
579 let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?;
580 let session = self.sessions.session_auth_info(session_id)?;
581 let privilege = self.executor.probe(session_id, false).await?;
582 let self_account = username == privilege["username"].as_str().unwrap_or("");
583 let sudo = sudo_password(payload);
584 let privileged = !self_account || sudo.is_some() || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or(""));
585 let auth = Auth { privileged, sudo_password: sudo };
586 let detail = self.detail(session_id, &username, &auth).await?;
587 // root's SSH keys are managed (via root session or sudo); other system accounts stay read-only.
588 if detail.public["account"]["system"] == true {
589 return Err(fail("USERS_PROTECTED_USER", "Keys of system accounts are shown read-only."));
590 }
591 let (file, content) = if action == "add" {
592 let key = keys::normalize_public_key(&payload["publicKey"], payload.get("comment").unwrap_or(&Value::Null))?;
593 for candidate in &detail.key_files {
594 if candidate.entries.iter().any(|e| e.blob.as_deref() == Some(key.blob.as_str())) {
595 return Err(fail("USERS_KEY_DUPLICATE", &format!("This public key is already authorized in {}.", candidate.path)));
596 }
597 }
598 // New keys go only to the first AuthorizedKeysFile entry; CH-J never falls back silently.
599 let file = detail.key_files.first().filter(|f| f.manageable).ok_or_else(|| {
600 let reason = detail.key_files.first().and_then(|f| f.reason).unwrap_or("not found");
601 fail("USERS_KEYS_FILE_UNMANAGEABLE", &format!("The primary authorized_keys file cannot be managed safely ({reason}). See diagnostics."))
602 })?;
603 (file, keys::append_key(&file.raw, &key)?)
604 } else {
605 let fingerprint = js_string(&payload["fingerprint"]);
606 let line = js_number_value(&payload["line"]);
607 let file = detail
608 .key_files
609 .iter()
610 .find(|f| Some(f.path.as_str()) == payload["path"].as_str())
611 .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()))))
612 .filter(|f| f.manageable)
613 .ok_or_else(|| fail("USERS_KEYS_FILE_UNMANAGEABLE", "This key file cannot be changed safely."))?;
614 if self_account && session["authMethod"] == "privateKey" {
615 if session["publicKeyFingerprint"].as_str().is_some_and(|fp| fp == fingerprint) {
616 return Err(fail("USERS_KEY_IN_USE", "This key is used by the active CH-J session and is not removed."));
617 }
618 let remaining: usize = detail.key_files.iter().map(|f| f.entries.iter().filter(|e| e.valid).count()).sum();
619 if remaining <= 1 {
620 return Err(fail("USERS_KEY_LAST", "The last authorized key of the active session account is not removed."));
621 }
622 }
623 (file, keys::remove_key(&file.raw, &payload["line"], &fingerprint)?)
624 };
625 let expected = if file.kind == "file" { keys::sha256_hex(&file.raw) } else { "absent".into() };
626 let frames = vec![Frame { tag: "DATA".into(), data: content }];
627 self.run(session_id, vec!["keys-write".into(), username.clone(), file.path.clone(), expected], frames, &auth, &format!("users.keys.{action}"), 30_000).await?;
628 self.logger.warn("Remote authorized_keys changed.", json!({ "sessionId": session_id, "username": username, "action": action, "path": file.path }));
629 let path = file.path.clone();
630 let refreshed = self.detail(session_id, &username, &auth).await?;
631 Ok(json!({ "action": action, "username": username, "path": path, "detail": refreshed.public }))
632 }
634 pub async fn ssh_policy(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
635 let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?;
636 let policy = policy::normalize_policy(payload.get("policy").filter(|p| p.is_object()).unwrap_or(&json!({})), Some(&username))?;
637 // Password login for root is never enabled implicitly: it needs a separate explicit confirmation.
638 if username == "root" && (policy.password == "yes" || policy.permit_root_login == "yes") && payload["confirmRootPassword"] != true {
639 return Err(fail("USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED", "Allowing password login for root requires an explicit additional confirmation."));
640 }
641 let auth = Auth { privileged: true, sudo_password: sudo_password(payload) };
642 let credentials = payload.get("probeCredentials").filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({}));
643 let mut result = self.apply_policy_change(session_id, &username, Some(policy), &auth, &credentials, true).await?;
644 let detail = self.detail(session_id, &username, &auth).await?;
645 result["detail"] = detail.public;
646 Ok(result)
647 }
649 fn account_args(action: &str, username: &str, payload: &Value) -> CoreResult<Vec<String>> {
650 let u = username.to_string();
651 Ok(match action {
652 "delete" => vec!["delete".into(), u, if payload["removeHome"] == true { "1" } else { "0" }.into()],
653 "shell" => vec!["account".into(), "shell".into(), u, validate_shell(&payload["shell"])?],
654 "comment" => vec!["account".into(), "comment".into(), u, validate_comment(&payload["comment"])?],
655 "home" => vec!["account".into(), "home".into(), u, validate_home(&payload["home"])?, if payload["moveHome"] == true { "1" } else { "0" }.into()],
656 "primary" | "group-add" | "group-remove" => vec!["account".into(), action.into(), u, validate_name(&payload["group"], "USERS_GROUP_INVALID")?],
657 _ => vec!["account".into(), action.into(), u],
658 })
659 }
661 fn assert_mutable(user: &Value, action: &str) -> CoreResult<()> {
662 match user["protection"].as_str() {
663 Some("root") => Err(fail("USERS_PROTECTED_USER", "The root account is protected and cannot be changed here.")),
664 Some("system") => Err(fail("USERS_PROTECTED_USER", "System accounts are protected and cannot be changed here.")),
665 Some("session") => Err(fail(
666 "USERS_PROTECTED_USER",
667 if action == "password" {
668 "Change the password of the active session account with passwd in the terminal; CH-J would otherwise invalidate the stored login."
669 } else {
670 "The account used by the active SSH session is protected against changes that could lock you out."
671 },
672 )),
673 _ => Ok(()),
674 }
675 }
677 fn assert_not_last_admin(inventory: &Value, user: &Value, action: &str, payload: &Value) -> CoreResult<()> {
678 if inventory["privilege"]["mode"] == "root" {
679 return Ok(());
680 }
681 if action == "shell" && is_login_shell(&js_string(&payload["shell"])) {
682 return Ok(());
683 }
684 let users = inventory["users"].as_array().cloned().unwrap_or_default();
685 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();
686 if functional.iter().any(|c| c["username"] == user["username"]) && functional.iter().all(|c| c["username"] == user["username"]) {
687 return Err(fail("USERS_LAST_ADMIN", "This is the last working administrator account; the change was refused."));
688 }
689 Ok(())
690 }
692 async fn create(&self, session_id: &str, payload: &Value, sudo: Option<String>) -> CoreResult<Value> {
693 let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?;
694 let options = json!({ "sudoPassword": sudo, "privileged": true });
695 let inventory = self.inventory(session_id, &options).await?;
696 if inventory.public["users"].as_array().is_some_and(|u| u.iter().any(|c| c["username"] == username.as_str())) {
697 return Err(fail("USERS_USER_EXISTS", "The account already exists."));
698 }
699 let shells = inventory.public["shells"].as_array().cloned().unwrap_or_default();
700 let fallback = if shells.iter().any(|s| s == "/bin/bash") { "/bin/bash" } else { "/bin/sh" };
701 let fallback_value = json!(fallback);
702 let shell = validate_shell(if truthy(&payload["shell"]) { &payload["shell"] } else { &fallback_value })?;
703 let home = if truthy(&payload["home"]) { validate_home(&payload["home"])? } else { String::new() };
704 let primary = if truthy(&payload["primaryGroup"]) { validate_name(&payload["primaryGroup"], "USERS_GROUP_INVALID")? } else { String::new() };
705 let mut group_list: Vec<String> = Vec::new();
706 for group in payload["groups"].as_array().cloned().unwrap_or_default() {
707 let name = validate_name(&group, "USERS_GROUP_INVALID")?;
708 if !group_list.contains(&name) {
709 group_list.push(name);
710 }
711 }
712 if group_list.len() > 32 {
713 return Err(fail("USERS_GROUP_INVALID", "Too many groups."));
714 }
715 let empty = json!("");
716 let comment = validate_comment(if truthy(&payload["comment"]) { &payload["comment"] } else { &empty })?;
717 let with_password = !matches!(&payload["password"], Value::Null) && payload["password"] != "";
718 let frames = if with_password { vec![Frame::text("SECRET", &validate_account_password(&payload["password"])?)] } else { vec![] };
719 let args = vec![
720 "create".into(),
721 username.clone(),
722 shell,
723 home,
724 if payload["createHome"] == false { "0" } else { "1" }.into(),
725 primary,
726 group_list.join(","),
727 comment,
728 if payload["admin"] == true { "1" } else { "0" }.into(),
729 if with_password { "1" } else { "0" }.into(),
730 ];
731 let auth = Auth { privileged: true, sudo_password: sudo };
732 let run = self.run(session_id, args, frames, &auth, "users.create", 60_000).await?;
733 let outcome = parse_step_markers(&run.stdout);
734 self.logger.warn("Remote account created.", json!({ "sessionId": session_id, "username": username, "result": outcome.result }));
735 let refreshed = self.inventory(session_id, &options).await?;
736 let created = refreshed.public["users"].as_array().and_then(|u| u.iter().find(|c| c["username"] == username.as_str())).cloned();
737 let mut warnings: Vec<String> = Vec::new();
738 if created.is_none() {
739 warnings.push("The server did not list the new account after creation.".into());
740 }
741 if payload["admin"] == true && created.as_ref().is_some_and(|c| !c["sudo"].as_str().unwrap_or("").starts_with("full")) {
742 warnings.push("sudo -l does not report full sudo rights for the new account.".into());
743 }
744 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"] }))
745 }
747 /// Stages, verifies, reloads and confirms one change of the CH-J managed sshd drop-in.
748 /// With `target_exists = false` (cleanup after deletion) the session account supplies the SSH context.
749 async fn apply_policy_change(&self, session_id: &str, username: &str, policy: Option<Policy>, auth: &Auth, credentials: &Value, target_exists: bool) -> CoreResult<Value> {
750 let privilege = self.executor.probe(session_id, false).await?;
751 let session = self.sessions.session_auth_info(session_id)?;
752 let session_user = privilege["username"].as_str().unwrap_or("").to_string();
753 let detail = self.detail(session_id, if target_exists { username } else { &session_user }, &Auth { privileged: true, sudo_password: auth.sudo_password.clone() }).await?;
754 let ssh = &detail.public["ssh"];
755 if target_exists && detail.public["account"]["system"] == true {
756 return Err(fail("USERS_PROTECTED_USER", "SSH access of system accounts is not managed by CH-J."));
757 }
758 if ssh["available"] != true {
759 return Err(fail("USERS_SSHD_MISSING", "OpenSSH server was not found."));
760 }
761 if ssh["layoutSupported"] != true {
762 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."));
763 }
764 if ssh["syntaxOk"] == false {
765 return Err(fail("USERS_SSH_CONFIG_ALREADY_INVALID", &format!("The current SSH configuration is invalid: {}", ssh["syntaxMessage"].as_str().unwrap_or(""))));
766 }
767 let managed = &detail.managed;
768 if managed.state == "unsafe" || managed.state == "unreadable" {
769 return Err(fail("USERS_SSH_MANAGED_FILE_UNSAFE", "The CH-J managed SSH file is not a readable regular file."));
770 }
771 if !managed.consistent {
772 return Err(fail("USERS_SSH_MANAGED_FILE_MODIFIED", &format!("{MANAGED_PATH} was edited manually; CH-J will not overwrite it.")));
773 }
774 let mut policies = managed.policies.clone();
775 match &policy {
776 Some(p) if !p.is_default() => {
777 policies.insert(username.to_string(), p.clone());
778 }
779 _ => {
780 policies.remove(username);
781 }
782 }
783 let content = policy::generate_managed_config(&policies);
784 let result_policy = policy.clone().unwrap_or_default().to_value();
785 if content == managed.content {
786 return Ok(json!({ "changed": false, "applied": false, "policy": result_policy }));
787 }
788 let self_account = username == session_user;
789 let method = if session["authMethod"] == "privateKey" { "publickey" } else { "password" };
790 let client_address = privilege["clientAddress"].as_str().unwrap_or("").to_string();
791 if client_address.parse::<std::net::IpAddr>().is_err() {
792 return Err(fail("USERS_SSH_CLIENT_UNKNOWN", "The client address of the SSH session is unknown, so lockout protection cannot be verified."));
793 }
794 if self_account {
795 let requirements = self.sessions.probe_login_requirements(session_id, credentials)?;
796 if requirements["ready"] != true {
797 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(""))));
798 }
799 }
800 let addresses = detail.addresses.clone();
801 let session_tuple = format!("{session_user}|{client_address}");
802 let mut tuples: Vec<String> = if target_exists { vec![format!("{username}|{}", addresses.ipv4), format!("{username}|{}", addresses.ipv6), session_tuple.clone()] } else { vec![session_tuple.clone()] };
803 let mut seen = std::collections::HashSet::new();
804 tuples.retain(|t| seen.insert(t.clone()));
805 let session_groups: Vec<String> = 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() };
806 let mut args = vec!["ssh-apply".to_string(), managed.sha256.clone().unwrap_or_else(|| "absent".into()), SSH_WATCHDOG_SECONDS.to_string()];
807 args.extend(tuples);
808 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);
809 let sessions = self.sessions.clone();
810 let session_id_owned = session_id.to_string();
811 let username_owned = username.to_string();
812 let credentials_owned = credentials.clone();
813 let policy_for_check = policy.clone();
814 let interact = move |io: Interaction| async move {
815 static STAGED: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_STAGED\r?\n").unwrap());
816 static ROLLED_BACK: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_ROLLED_BACK=[^\n]*\n").unwrap());
817 static RELOADED: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_RELOADED=([^\n]*)\n").unwrap());
818 static CONFIRMED: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_CONFIRMED=([^\n]*)\n").unwrap());
819 let staged = io.wait_for(&STAGED, Duration::from_secs(60)).await?;
820 let before = parse_effective_blocks(&staged.before, "BEFORE");
821 let after = parse_effective_blocks(&staged.before, "AFTER");
822 let mut problems: Vec<String> = Vec::new();
823 if let (Some(policy), true) = (&policy_for_check, target_exists) {
824 for (family, label) in [("ipv4", "IPv4"), ("ipv6", "IPv6")] {
825 match after.get(&format!("{username_owned}|{}", addresses.get(family))) {
826 Some(effective) if effective.errors.is_empty() => problems.extend(policy::verify_policy_effect(policy, &username_owned, effective, label, &Default::default())),
827 _ => problems.push(format!("{label}: sshd -T did not return a usable result")),
828 }
829 }
830 }
831 let context = AccessContext { username: &session_user, groups: &session_groups, address: &client_address, method: Some(method), keyboard_interactive: true };
832 let lockout = match after.get(&session_tuple) {
833 Some(after_session) if after_session.errors.is_empty() => {
834 if self_account {
835 let evaluation = policy::evaluate_access(after_session, &AccessContext { username: &username_owned, groups: &session_groups, address: &client_address, method: Some(method), keyboard_interactive: true });
836 (evaluation.method_allowed != Some(true)).then(|| format!("The active session ({method} from {client_address}) would no longer be allowed to log in."))
837 } else {
838 // DenyUsers lines of other accounts appear in every sshd -T dump, so compare the evaluated result.
839 match before.get(&session_tuple) {
840 Some(before_session) if comparable_access(before_session, &context) == comparable_access(after_session, &context) => None,
841 _ => Some("The change would alter the effective SSH access of the active session account.".to_string()),
842 }
843 }
844 }
845 _ => Some("sshd -T could not evaluate the active session account.".to_string()),
846 };
847 if !problems.is_empty() || lockout.is_some() {
848 io.send("ROLLBACK");
849 io.wait_for(&ROLLED_BACK, Duration::from_secs(30)).await?;
850 return Ok(json!({ "rolledBack": true, "problems": problems, "lockout": lockout }));
851 }
852 io.send("COMMIT");
853 let reloaded = io.wait_for(&RELOADED, Duration::from_secs(60)).await?;
854 if self_account {
855 if let Err(error) = sessions.probe_login(&session_id_owned, &credentials_owned).await {
856 io.send("ROLLBACK");
857 io.wait_for(&ROLLED_BACK, Duration::from_secs(60)).await?;
858 return Ok(json!({ "rolledBack": true, "problems": [], "lockout": null, "probeError": error.message }));
859 }
860 }
861 io.send("CONFIRM");
862 let confirmed = io.wait_for(&CONFIRMED, Duration::from_secs(30)).await?;
863 let group = |m: &crate::core::ssh::privileged::Matched| m.groups.get(1).cloned().flatten().unwrap_or_default();
864 Ok(json!({ "rolledBack": false, "reload": clean_text(&group(&reloaded), 200), "backup": clean_text(&group(&confirmed), 300), "probed": self_account }))
865 };
866 let (_, outcome) = self.executor.run_interactive(session_id, options, Some(interact)).await?;
867 let outcome = outcome.unwrap_or_else(|| json!({ "rolledBack": true, "problems": ["no result"], "lockout": null }));
868 if outcome["rolledBack"] == true {
869 let reason = if !outcome["lockout"].is_null() { "lockout" } else if outcome.get("probeError").is_some() { "probe" } else { "not-effective" };
870 self.logger.warn("SSH policy change rolled back.", json!({ "sessionId": session_id, "username": username, "reason": reason }));
871 if let Some(lockout) = outcome["lockout"].as_str() {
872 return Err(fail("USERS_SSH_LOCKOUT_PREVENTED", &format!("{lockout} The previous configuration was restored.")));
873 }
874 if let Some(probe) = outcome.get("probeError").and_then(Value::as_str) {
875 return Err(fail("USERS_SSH_PROBE_FAILED", &format!("{probe} The previous SSH configuration was restored and reloaded.")));
876 }
877 let problems: Vec<String> = outcome["problems"].as_array().map(|p| p.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
878 return Err(fail("USERS_SSH_POLICY_NOT_EFFECTIVE", &format!("Existing sshd rules take precedence ({}). The previous configuration was restored.", problems.join("; "))));
879 }
880 self.logger.warn("SSH policy applied and confirmed.", json!({ "sessionId": session_id, "username": username, "probed": outcome["probed"] }));
881 Ok(json!({ "changed": true, "applied": true, "policy": result_policy, "reload": outcome["reload"], "backup": outcome["backup"], "probed": outcome["probed"] }))
882 }
885/// authorized_keys file reported by `remoteUsers.sh detail`.
886pub struct KeyFile {
887 pub path: String,
888 pub spec: String,
889 pub kind: String,
890 pub meta: String,
891 pub dir_meta: String,
892 pub manageable: bool,
893 pub reason: Option<&'static str>,
894 pub entries: Vec<keys::KeyEntry>,
895 pub raw: Vec<u8>,
898impl KeyFile {
899 pub fn public(&self) -> Value {
900 json!({
901 "path": self.path,
902 "spec": self.spec,
903 "type": self.kind,
904 "meta": self.meta,
905 "dirMeta": self.dir_meta,
906 "manageable": self.manageable,
907 "reason": self.reason,
908 "entries": self.entries.iter().map(keys::KeyEntry::public).collect::<Vec<_>>()
909 })
910 }
913static TRAVERSAL: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)").unwrap());
915fn parse_key_files(text: &str, home: &str) -> Vec<KeyFile> {
916 let mut files = Vec::new();
917 for block in text.split("\x1eCHJ_KEYFILE=").skip(1) {
918 let mut block_lines = block.split('\n');
919 let first = block_lines.next().unwrap_or("");
920 let path_line = first.strip_suffix('\r').unwrap_or(first);
921 let values = key_values(&block_lines.collect::<Vec<_>>().join("\n"));
922 let path = clean_text(path_line, 300);
923 let raw = values.get("data").map(|d| B64.decode(d).unwrap_or_default()).unwrap_or_default();
924 let inside_home = !home.is_empty() && home != "/" && path.starts_with(&format!("{home}/")) && !TRAVERSAL.is_match(&path);
925 let kind = values.get("type").cloned().unwrap_or_else(|| "unknown".into());
926 let reason: Option<&'static str> = if !inside_home {
927 Some("outside-home")
928 } else {
929 match kind.as_str() {
930 "symlink" => Some("symlink"),
931 "other" => Some("not-regular"),
932 "unreadable" => Some("unreadable"),
933 "unsupported" => Some("unsupported-token"),
934 _ if values.get("tooLarge").map(String::as_str) == Some("1") => Some("too-large"),
935 "file" | "absent" | "absent-dir" => None,
936 _ => Some("unknown"),
937 }
938 };
939 files.push(KeyFile {
940 path,
941 spec: clean_text(values.get("spec").map(String::as_str).unwrap_or(""), 200),
942 entries: if kind == "file" { keys::parse_authorized_keys(&raw) } else { Vec::new() },
943 meta: clean_text(values.get("meta").map(String::as_str).unwrap_or(""), 120),
944 dir_meta: clean_text(values.get("dirMeta").map(String::as_str).unwrap_or(""), 120),
945 manageable: reason.is_none(),
946 reason,
947 kind,
948 raw,
949 });
950 }
951 files
954#[cfg(test)]
955mod tests {
956 use super::*;
958 #[test]
959 fn parsers() {
960 let out = parse_step_markers("x\x1eCHJ_STEP=useradd|ok|created\n\x1eCHJ_SUDO=full-all\n\x1eCHJ_RESULT=ok\n");
961 assert_eq!(out.result, "ok");
962 assert_eq!(out.sudo.as_deref(), Some("full-all"));
963 assert_eq!(out.steps[0]["message"], "created");
964 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");
965 assert_eq!(blocks["bob|1.2.3.4"].get("passwordauthentication"), Some("no"));
966 assert!(validate_home(&json!("/etc/x")).is_err());
967 assert_eq!(validate_home(&json!("/var/lib/app/")).unwrap(), "/var/lib/app");
968 assert!(validate_home(&json!("/var")).is_err());
969 assert!(validate_shell(&json!("/bin/../bin/sh")).is_err());
970 assert!(validate_comment(&json!("a:b")).is_err());
971 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");
972 assert!(files[0].manageable, "{:?} {:?} {}", files[0].reason, files[0].path, files[0].kind);
973 assert_eq!(files[1].reason, Some("outside-home"));
974 }
977#[cfg(test)]
978#[path = "../../../tests/support/users_safety.rs"]
979mod safety_tests;
SHA-256ba26240b79b4fb75cd8efb8fdf13655300b252b9e04d163f7b23b7313d05b1b2

CH-J Proprietary Software License 1.14 · A fonte é fornecida sob o CH-J Proprietary Software License 1.14. A sua disponibilidade não altera os termos licença nem concede direitos adicionais.

Ficheiro SHA-25677380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Ficheiros