CH-J ToolsPalvelimen hallinta yli SSH
Valikko
Julkaistu lähde

CH-J Tools

Selaile hakemistoja ja tiedostoja tietyn sovelluksen julkaisua varten.

Lataa lähde ZIP
CH-J / Server Manager 1704 tiedostot
0.1.2-beta Lisätty: · Rakennettu:
linux.rs Lisätty: Kirjaamaton päivä 59,4 KB · 1027 rivit
Koodi Lataa tiedosto
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;
25const SSH_WATCHDOG_SECONDS: u64 = 120;
26const FORBIDDEN_HOMES: [&str; 14] = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"];
27const ACCOUNT_ACTIONS: [&str; 13] = ["lock", "unlock", "expire", "unexpire", "password", "shell", "comment", "home", "primary", "group-add", "group-remove", "sudo-grant", "sudo-revoke"];
28/// Actions that can remove the last working administrator.
29const ADMIN_REDUCING: [&str; 5] = ["delete", "lock", "expire", "sudo-revoke", "shell"];
31fn alias(action: &str) -> &str {
32 match action {
33 "grantAdmin" => "sudo-grant",
34 "revokeAdmin" => "sudo-revoke",
35 "setPassword" => "password",
36 "setShell" => "shell",
37 "setComment" => "comment",
38 "setHome" => "home",
39 "setPrimaryGroup" => "primary",
40 "addGroup" => "group-add",
41 "removeGroup" => "group-remove",
42 other => other,
43 }
46pub fn validate_name(value: &Value, code: &str) -> CoreResult<String> {
47 let name = js_string(value).trim().to_string();
48 if !policy::is_policy_name(&name) {
49 return Err(fail(code, "Names must start with a lowercase letter or underscore and contain only a-z, 0-9, _ or - (max. 32)."));
50 }
51 Ok(name)
54static SHELL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+-]{1,127}$").unwrap());
55static HOME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/@+-]{1,254}$").unwrap());
56static HOME_BAD: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)|//").unwrap());
58pub fn validate_shell(value: &Value) -> CoreResult<String> {
59 let shell = js_string(value).trim().to_string();
60 if !SHELL.is_match(&shell) || shell.contains("/../") || shell.ends_with("/..") {
61 return Err(fail("USERS_SHELL_INVALID", "Invalid shell path."));
62 }
63 Ok(shell)
66pub fn validate_home(value: &Value) -> CoreResult<String> {
67 let home = js_string(value).trim().trim_end_matches('/').to_string();
68 if !HOME.is_match(&home) || HOME_BAD.is_match(&home) {
69 return Err(fail("USERS_HOME_INVALID", "The home directory must be an absolute path without special characters."));
70 }
71 if FORBIDDEN_HOMES.iter().any(|root| home == *root || (*root != "/var" && home.starts_with(&format!("{root}/")))) {
72 return Err(fail("USERS_HOME_INVALID", "System directories cannot be used as a home directory."));
73 }
74 Ok(home)
77pub fn validate_comment(value: &Value) -> CoreResult<String> {
78 let comment = js_string(value).trim().to_string();
79 if comment.encode_utf16().count() > 100 || comment.chars().any(|c| c == ':' || c == '\\' || (c as u32) < 0x20 || c as u32 == 0x7f) {
80 return Err(fail("USERS_COMMENT_INVALID", "The full name may contain up to 100 characters without ':' or control characters."));
81 }
82 Ok(comment)
85pub fn validate_account_password(value: &Value) -> CoreResult<String> {
86 let password = js_string(value);
87 if password.is_empty() || password.encode_utf16().count() > 256 || password.contains(['\0', '\r', '\n']) {
88 return Err(fail("USERS_PASSWORD_INVALID", "The password must contain 1-256 characters and no line breaks."));
89 }
90 Ok(password)
93struct Managed {
94 state: &'static str,
95 sha256: Option<String>,
96 content: String,
97 policies: Policies,
98 consistent: bool,
101fn parse_managed_section(text: &str) -> Managed {
102 let values = key_values(text);
103 let empty = |state, consistent| Managed { state, sha256: None, content: String::new(), policies: Policies::new(), consistent };
104 if values.get("unsafe").map(String::as_str) == Some("1") {
105 return empty("unsafe", false);
106 }
107 if values.get("unreadable").map(String::as_str) == Some("1") {
108 return empty("unreadable", false);
109 }
110 match values.get("sha256").map(String::as_str) {
111 None | Some("absent") => Managed { state: "absent", sha256: Some("absent".into()), content: String::new(), policies: Policies::new(), consistent: true },
112 Some(sha) => {
113 let content = String::from_utf8_lossy(&B64.decode(values.get("data").map(String::as_str).unwrap_or("")).unwrap_or_default()).to_string();
114 let parsed = policy::parse_managed_config(&content);
115 Managed { state: "present", sha256: Some(sha.to_string()), content, policies: parsed.policies, consistent: parsed.consistent }
116 }
117 }
120struct Passwd {
121 username: String,
122 uid: Option<i64>,
123 gid: i64,
124 comment: String,
125 home: String,
126 shell: String,
129fn parse_passwd(line: &str) -> Passwd {
130 let parts: Vec<&str> = line.split(':').collect();
131 let field = |i: usize| parts.get(i).copied().unwrap_or("");
132 Passwd {
133 username: field(0).to_string(),
134 uid: js_number(field(2)).filter(|n| n.fract() == 0.0).map(|n| n as i64),
135 gid: js_number(field(3)).unwrap_or(f64::NAN) as i64,
136 comment: clean_text(field(4).split(',').next().unwrap_or(""), 100),
137 home: clean_text(field(5), 255),
138 shell: clean_text(field(6), 255),
139 }
142impl Passwd {
143 fn value(&self) -> Map<String, Value> {
144 let mut map = Map::new();
145 map.insert("username".into(), json!(self.username));
146 map.insert("uid".into(), json!(self.uid));
147 map.insert("gid".into(), json!(self.gid));
148 map.insert("comment".into(), json!(self.comment));
149 map.insert("home".into(), json!(self.home));
150 map.insert("shell".into(), json!(self.shell));
151 map
152 }
155fn parse_shadow_state(value: &str) -> Map<String, Value> {
156 let parts: Vec<&str> = value.split(':').collect();
157 let state = parts.first().copied().unwrap_or("unknown");
158 let last = parts.get(1).copied().unwrap_or("");
159 let expire = parts.get(2).copied().unwrap_or("");
160 let expire_days = if expire.is_empty() { None } else { js_number(expire).filter(|n| n.is_finite()) };
161 let mut map = Map::new();
162 map.insert("password".into(), json!(if ["set", "locked", "none", "empty"].contains(&state) { state } else { "unknown" }));
163 map.insert("lastChangeDays".into(), if last.is_empty() { Value::Null } else { js_number(last).map(Value::from).unwrap_or(Value::Null) });
164 map.insert("expireDays".into(), expire_days.map(Value::from).unwrap_or(Value::Null));
165 map.insert("expired".into(), json!(expire_days.is_some_and(|d| d * 86_400_000.0 <= chrono::Utc::now().timestamp_millis() as f64)));
166 map
169fn unknown_shadow(expired: Value) -> Map<String, Value> {
170 let mut map = Map::new();
171 map.insert("password".into(), json!("unknown"));
172 map.insert("lastChangeDays".into(), Value::Null);
173 map.insert("expireDays".into(), Value::Null);
174 map.insert("expired".into(), expired);
175 map
178pub fn is_login_shell(shell: &str) -> bool {
179 !shell.is_empty() && !shell.ends_with("/nologin") && !shell.ends_with("/false")
182#[derive(Debug, Clone, Default)]
183pub struct StepOutcome {
184 pub steps: Vec<Value>,
185 pub result: String,
186 pub sudo: Option<String>,
189pub fn parse_step_markers(stdout: &str) -> StepOutcome {
190 static STEP: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n").unwrap());
191 static RESULT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_RESULT=([a-z]+)").unwrap());
192 static SUDO: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_SUDO=([a-z-]+)").unwrap());
193 StepOutcome {
194 steps: STEP.captures_iter(stdout).map(|c| json!({ "step": &c[1], "status": &c[2], "message": clean_text(&c[3], 300) })).collect(),
195 result: RESULT.captures(stdout).map(|c| c[1].to_string()).unwrap_or_else(|| "unknown".into()),
196 sudo: SUDO.captures(stdout).map(|c| c[1].to_string()),
197 }
200/// `\x1eCHJ_<marker>=<key>\n<body>` blocks up to the next `\x1eCHJ_` or the end.
201pub fn parse_effective_blocks(text: &str, marker: &str) -> BTreeMap<String, Effective> {
202 let tag = format!("\x1eCHJ_{marker}=");
203 let mut blocks = BTreeMap::new();
204 let mut rest = text;
205 while let Some(start) = rest.find(&tag) {
206 let after = &rest[start + tag.len()..];
207 let Some(newline) = after.find('\n') else { break };
208 let key = &after[..newline];
209 let body_start = &after[newline + 1..];
210 let end = body_start.find("\x1eCHJ_").unwrap_or(body_start.len());
211 if !key.is_empty() {
212 blocks.insert(key.to_string(), policy::parse_effective(&body_start[..end]));
213 }
214 rest = &body_start[end..];
215 }
216 blocks
219const ACCESS_LIST_KEYS: [&str; 4] = ["denyusers", "allowusers", "denygroups", "allowgroups"];
221fn comparable_access(effective: &Effective, context: &AccessContext) -> String {
222 let evaluation = policy::evaluate_access(effective, context);
223 let settings: Vec<(&String, &Value)> = effective.values.iter().filter(|(k, _)| !ACCESS_LIST_KEYS.contains(&k.as_str())).collect();
224 serde_json::to_string(&json!({ "allowed": evaluation.allowed_value(), "methods": evaluation.methods, "settings": settings })).unwrap_or_default()
227/// Inventory with internal fields kept out of IPC.
228pub struct Inventory {
229 pub public: Value,
230 pub policies: Policies,
233/// Detail with internal fields kept out of IPC.
234pub struct Detail {
235 pub public: Value,
236 pub key_files: Vec<KeyFile>,
237 managed: Managed,
238 addresses: Addresses,
239 groups: Vec<String>,
242pub struct LinuxUsers {
243 pub sessions: Arc<SessionManager>,
244 pub executor: Arc<PrivilegedExecutor>,
245 pub logger: Arc<Logger>,
248pub struct Auth {
249 pub privileged: bool,
250 pub sudo_password: Option<String>,
253fn sudo_password(payload: &Value) -> Option<String> {
254 match payload.get("sudoPassword") {
255 Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
256 _ => None,
257 }
260impl LinuxUsers {
261 async fn run(&self, session_id: &str, args: Vec<String>, frames: Vec<Frame>, auth: &Auth, purpose: &str, timeout_ms: u64) -> CoreResult<RunResult> {
262 self.executor.run(session_id, self.options(args, frames, auth, purpose, timeout_ms)).await
263 }
265 fn options(&self, args: Vec<String>, frames: Vec<Frame>, auth: &Auth, purpose: &str, timeout_ms: u64) -> RunOptions {
266 RunOptions {
267 operation: purpose.to_string(),
268 args,
269 frames,
270 privileged: auth.privileged,
271 sudo_password: auth.sudo_password.clone(),
272 purpose: purpose.to_string(),
273 error_prefix: "USERS_".into(),
274 timeout: Duration::from_millis(timeout_ms),
275 ..Default::default()
276 }
277 }
279 fn wants_privilege(privilege: &Value, options: &Value) -> bool {
280 sudo_password(options).is_some() || options["privileged"] == true || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or(""))
281 }
283 pub async fn privileges(&self, session_id: &str, refresh: bool) -> CoreResult<Value> {
284 let probe = self.executor.probe(session_id, refresh).await?;
285 let mut out = Map::new();
286 out.insert("platform".into(), json!("linux"));
287 if let Some(map) = probe.as_object() {
288 for (k, v) in map {
289 out.insert(k.clone(), v.clone());
290 }
291 }
292 Ok(Value::Object(out))
293 }
295 /// Lists accounts. Without privileges only public account data is read; shadow state and the
296 /// effective sudo rights need root and are fetched automatically when no password is required.
297 pub async fn inventory(&self, session_id: &str, options: &Value) -> CoreResult<Inventory> {
298 let privilege = self.executor.probe(session_id, false).await?;
299 let privileged = Self::wants_privilege(&privilege, options);
300 let run = self.run(session_id, vec![], vec![], &Auth { privileged, sudo_password: sudo_password(options) }, "users.inventory", 90_000).await?;
301 let privilege = self.executor.probe(session_id, false).await?;
302 Ok(self.parse_inventory(session_id, &run.stdout, privileged, privilege))
303 }
305 fn parse_inventory(&self, session_id: &str, stdout: &str, privileged: bool, privilege: Value) -> Inventory {
306 let sections = split_sections(stdout);
307 let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or("");
308 let meta = key_values(section("meta"));
309 let mut tools: Vec<String> = lines(section("meta")).into_iter().filter_map(|l| l.strip_prefix("tool=").map(str::to_string)).collect();
310 tools.sort();
311 tools.dedup();
312 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;
313 let groups: Vec<(String, i64, Vec<String>)> = lines(section("group"))
314 .into_iter()
315 .map(|line| {
316 let parts: Vec<&str> = line.split(':').collect();
317 let members = parts.get(3).copied().unwrap_or("").split(',').filter(|s| !s.is_empty()).map(str::to_string).collect();
318 (parts.first().copied().unwrap_or("").to_string(), js_number(parts.get(2).copied().unwrap_or("")).unwrap_or(f64::NAN) as i64, members)
319 })
320 .filter(|(name, _, _)| !name.is_empty())
321 .collect();
322 let group_by_gid: BTreeMap<i64, String> = groups.iter().map(|(n, g, _)| (*g, n.clone())).collect();
323 let shadow: BTreeMap<String, Map<String, Value>> = lines(section("shadow"))
324 .into_iter()
325 .map(|line| {
326 let (name, rest) = line.split_once(':').unwrap_or((line, ""));
327 (name.to_string(), parse_shadow_state(rest))
328 })
329 .collect();
330 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();
331 let admin_groups: Vec<String> = lines(section("admingroups")).into_iter().map(str::to_string).collect();
332 let managed = parse_managed_section(section("managed"));
333 let session_user = privilege["username"].as_str().unwrap_or("").to_string();
334 let mut users: Vec<Value> = lines(section("passwd"))
335 .into_iter()
336 .map(parse_passwd)
337 .filter(|u| !u.username.is_empty() && u.uid.is_some())
338 .map(|user| {
339 let uid = user.uid.unwrap_or(0);
340 let state = shadow.get(&user.username).cloned().unwrap_or_else(|| unknown_shadow(Value::Null));
341 let supplementary: Vec<String> = groups.iter().filter(|(_, _, m)| m.contains(&user.username)).map(|(n, _, _)| n.clone()).collect();
342 let primary = group_by_gid.get(&user.gid).cloned();
343 let member_admin: Vec<&str> = ["sudo", "wheel", "admin"].into_iter().filter(|n| supplementary.iter().any(|s| s == n) || primary.as_deref() == Some(*n)).collect();
344 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() };
345 let system = uid != 0 && (uid < uid_min || uid == 65534);
346 let ssh_policy = managed.policies.get(&user.username).map(Policy::to_value).unwrap_or(Value::Null);
347 let mut map = user.value();
348 map.insert("displayName".into(), json!(user.comment));
349 map.insert("primaryGroup".into(), json!(primary.clone().unwrap_or_else(|| user.gid.to_string())));
350 map.insert("groups".into(), json!(supplementary));
351 map.insert("system".into(), json!(system));
352 map.insert("loginShell".into(), json!(is_login_shell(&user.shell)));
353 for (k, v) in &state {
354 map.insert(k.clone(), v.clone());
355 }
356 map.insert("locked".into(), json!(state.get("password").and_then(Value::as_str) == Some("locked")));
357 map.insert("passwordState".into(), state.get("password").cloned().unwrap_or(json!("unknown")));
358 map.insert("sudo".into(), json!(sudo_state));
359 map.insert("adminGroups".into(), json!(member_admin));
360 map.insert("admin".into(), json!(uid == 0 || if sudo_state == "unknown" { !member_admin.is_empty() } else { sudo_state.starts_with("full") }));
361 map.insert("sshPolicy".into(), ssh_policy);
362 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 });
363 Value::Object(map)
364 })
365 .collect();
366 users.sort_by(|a, b| {
367 (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("")))
368 });
369 let shells: Vec<String> = lines(section("shells")).into_iter().filter(|s| SHELL.is_match(s)).map(str::to_string).collect();
370 let public = json!({
371 "platform": "linux",
372 "sessionId": session_id,
373 "privileged": privileged,
374 "privilege": privilege,
375 "server": {
376 "os": clean_text(meta.get("os").map(String::as_str).unwrap_or(""), 120),
377 "osId": clean_text(meta.get("osId").map(String::as_str).unwrap_or(""), 40),
378 "uidMin": uid_min,
379 "tools": tools,
380 "sshLayout": meta.get("layout").cloned().unwrap_or_else(|| "unknown".into()),
381 "managedPath": MANAGED_PATH
382 },
383 "shells": shells,
384 "groups": groups.iter().map(|(n, g, m)| json!({ "name": n, "gid": g, "members": m })).collect::<Vec<_>>(),
385 "adminGroups": admin_groups,
386 "managedPolicy": { "state": managed.state, "consistent": managed.consistent, "users": managed.policies.keys().collect::<Vec<_>>() },
387 "users": users,
388 "collectedAt": now_iso()
389 });
390 Inventory { public, policies: managed.policies }
391 }
393 pub async fn list_compat(&self, session_id: &str) -> CoreResult<Value> {
394 let inventory = self.inventory(session_id, &json!({})).await?;
395 Ok(Value::Array(inventory.public["users"].as_array().cloned().unwrap_or_default().into_iter().filter(|u| u["uid"] == 0 || u["system"] != true).collect()))
396 }
398 pub async fn detail_public(&self, session_id: &str, options: &Value) -> CoreResult<Value> {
399 let username = validate_name(&options["username"], "USERS_NAME_INVALID")?;
400 let privilege = self.executor.probe(session_id, false).await?;
401 let privileged = Self::wants_privilege(&privilege, options);
402 Ok(self.detail(session_id, &username, &Auth { privileged, sudo_password: sudo_password(options) }).await?.public)
403 }
405 pub async fn detail(&self, session_id: &str, username: &str, auth: &Auth) -> CoreResult<Detail> {
406 let privilege = self.executor.probe(session_id, false).await?;
407 let session = self.sessions.session_auth_info(session_id)?;
408 let addresses = family_addresses(privilege["clientAddress"].as_str().unwrap_or(""));
409 let run = self.run(session_id, vec![username.into(), addresses.ipv4.clone(), addresses.ipv6.clone()], vec![], auth, "users.detail", 60_000).await?;
410 let sections = split_sections(&run.stdout);
411 let section = |name: &str| sections.get(name).map(String::as_str).unwrap_or("");
412 let account_values = key_values(section("account"));
413 let account = parse_passwd(account_values.get("passwd").map(String::as_str).unwrap_or(""));
414 if account.username != username {
415 return Err(fail("USERS_PROTOCOL_ERROR", "The server returned an unexpected account."));
416 }
417 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();
418 let state = account_values.get("shadow").filter(|s| !s.is_empty()).map(|s| parse_shadow_state(s)).unwrap_or_else(|| unknown_shadow(Value::Null));
419 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;
420 let uid = account.uid.unwrap_or(0);
421 let system = uid != 0 && (uid < uid_min || uid == 65534);
422 let ssh_values = key_values(section("ssh"));
423 let managed = parse_managed_section(section("managed"));
424 let policy = managed.policies.get(username).cloned().unwrap_or_default();
425 let mut effective = Map::new();
426 let mut warnings: Vec<String> = Vec::new();
427 let mut listener: Option<Value> = None;
428 let mut keys_command: Option<String> = None;
429 let mut processed = false;
430 for (family, label) in [("ipv4", "effective4"), ("ipv6", "effective6")] {
431 let Some(text) = sections.get(label) else { continue };
432 processed = true;
433 let parsed = policy::parse_effective(text);
434 let address = addresses.get(family);
435 let evaluation = policy::evaluate_access(&parsed, &AccessContext { username, groups: &groups, address: &address, method: None, keyboard_interactive: true });
436 effective.insert(
437 family.into(),
438 json!({
439 "address": address,
440 "sample": addresses.sample(family),
441 "passwordAuthentication": parsed.get("passwordauthentication").unwrap_or("unknown"),
442 "kbdInteractiveAuthentication": parsed.get("kbdinteractiveauthentication").or(parsed.get("challengeresponseauthentication")).unwrap_or("unknown"),
443 "pubkeyAuthentication": parsed.get("pubkeyauthentication").unwrap_or("unknown"),
444 "authenticationMethods": parsed.get("authenticationmethods").unwrap_or("any"),
445 "permitRootLogin": parsed.get("permitrootlogin").unwrap_or("unknown"),
446 "allowed": evaluation.allowed_value(),
447 "reasons": evaluation.reasons,
448 "methods": evaluation.methods,
449 "errors": parsed.errors.iter().take(5).collect::<Vec<_>>()
450 }),
451 );
452 if listener.is_none() {
453 listener = Some(policy::listener_families(&parsed));
454 }
455 if keys_command.is_none() {
456 if let Some(command) = parsed.get("authorizedkeyscommand").filter(|c| *c != "none") {
457 keys_command = Some(clean_text(command, 200));
458 }
459 }
460 }
461 if let Some(l) = &listener {
462 effective.insert("listener".into(), l.clone());
463 if l["ipv6"] == false {
464 warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy.".into());
465 }
466 if l["ipv4"] == false {
467 warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy.".into());
468 }
469 }
470 if processed {
471 effective.insert("authorizedKeysCommand".into(), keys_command.clone().map(Value::from).unwrap_or(Value::Null));
472 }
473 if keys_command.is_some() {
474 warnings.push("AuthorizedKeysCommand is configured; keys may also come from an external source that CH-J does not manage.".into());
475 }
476 if managed.state == "present" && !managed.consistent {
477 warnings.push(format!("{MANAGED_PATH} was edited manually; CH-J will not overwrite it."));
478 }
479 let key_files = parse_key_files(section("keys"), &account.home);
480 let self_account = username == privilege["username"].as_str().unwrap_or("");
481 let mut account_map = account.value();
482 account_map.insert("primaryGroup".into(), json!(groups.first().cloned().unwrap_or_else(|| account.gid.to_string())));
483 account_map.insert("groups".into(), json!(groups.iter().skip(1).collect::<Vec<_>>()));
484 account_map.insert("system".into(), json!(system));
485 account_map.insert("loginShell".into(), json!(is_login_shell(&account.shell)));
486 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));
487 account_map.insert("homeMeta".into(), json!(clean_text(account_values.get("homeMeta").map(String::as_str).unwrap_or(""), 120)));
488 for (k, v) in &state {
489 account_map.insert(k.clone(), v.clone());
490 }
491 account_map.insert("sudo".into(), json!(account_values.get("sudo").filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| "unknown".into())));
492 account_map.insert("protection".into(), if uid == 0 { json!("root") } else if system { json!("system") } else if self_account { json!("session") } else { Value::Null });
493 let ssh_layout = ssh_values.get("layout").cloned().unwrap_or_else(|| "unknown".into());
494 let public = json!({
495 "platform": "linux",
496 "sessionId": session_id,
497 "username": username,
498 "privileged": run.privileged && auth.privileged,
499 "self": self_account,
500 "session": { "authMethod": session["authMethod"], "username": privilege["username"], "clientAddress": privilege["clientAddress"] },
501 "account": Value::Object(account_map),
502 "ssh": {
503 "available": ssh_values.get("sshd").is_some_and(|v| v != "missing"),
504 "version": clean_text(ssh_values.get("version").map(String::as_str).unwrap_or(""), 160),
505 "layout": ssh_layout,
506 "layoutSupported": ssh_layout == "ok",
507 "service": clean_text(ssh_values.get("service").map(String::as_str).unwrap_or(""), 120),
508 "syntaxOk": ssh_values.get("syntax").map(|s| json!(s == "0")).unwrap_or(Value::Null),
509 "syntaxMessage": clean_text(ssh_values.get("syntaxMessage").map(String::as_str).unwrap_or(""), 400),
510 "managed": { "state": managed.state, "consistent": managed.consistent, "path": MANAGED_PATH },
511 "policy": policy.to_value(),
512 "effective": Value::Object(effective),
513 "warnings": warnings
514 },
515 "keys": { "files": key_files.iter().map(KeyFile::public).collect::<Vec<_>>(), "home": account.home },
516 "collectedAt": now_iso()
517 });
518 Ok(Detail { public, key_files, managed, addresses, groups })
519 }
521 pub async fn manage(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
522 let requested = js_string(&payload["action"]);
523 let action = alias(&requested).to_string();
524 let sudo = sudo_password(payload);
525 if action == "create" {
526 return self.create(session_id, payload, sudo).await;
527 }
528 if action != "delete" && !ACCOUNT_ACTIONS.contains(&action.as_str()) {
529 return Err(fail("USERS_ACTION_INVALID", "Unsupported user-management action."));
530 }
531 let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?;
532 let args = Self::account_args(&action, &username, payload)?;
533 let options = json!({ "sudoPassword": sudo, "privileged": true });
534 let before = self.inventory(session_id, &options).await?;
535 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."))?;
536 Self::assert_mutable(&user, &action)?;
537 if ADMIN_REDUCING.contains(&action.as_str()) {
538 Self::assert_not_last_admin(&before.public, &user, &action, payload)?;
539 }
540 if action == "group-remove" {
541 let group = before.public["groups"].as_array().and_then(|g| g.iter().find(|g| g["name"] == args[1].as_str())).cloned();
542 if group.is_some_and(|g| g["gid"] == user["gid"]) {
543 return Err(fail("USERS_GROUP_PRIMARY", "The primary group cannot be removed; change the primary group first."));
544 }
545 }
546 let frames = if action == "password" { vec![Frame::text("SECRET", &validate_account_password(&payload["password"])?)] } else { vec![] };
547 let auth = Auth { privileged: true, sudo_password: sudo.clone() };
548 let operation = crate::core::matrix::operation_by_key("remoteUsers.sh", &action, "debian").ok_or_else(|| fail("USERS_ACTION_INVALID", "Unsupported user-management action."))?;
549 let run = self.run(session_id, args, frames, &auth, operation, 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![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 /// SSH password login off for every account that can log in. With `dryRun` it only says who that is and who has no key.
650 pub async fn ssh_password_off_all(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
651 let auth = Auth { privileged: true, sudo_password: sudo_password(payload) };
652 // Whoever came in with a password would lose this very way in, and the test login could not pass.
653 if self.sessions.session_auth_info(session_id)?["authMethod"] != "privateKey" {
654 return Err(fail("USERS_SSH_SESSION_USES_PASSWORD", "This session logged in with a password. Connect with an SSH key first."));
655 }
656 let inventory = self.inventory(session_id, &json!({ "sudoPassword": auth.sudo_password.as_ref().map(|p| p.as_str()), "privileged": true })).await?;
657 let names: Vec<String> = inventory.public["users"].as_array().into_iter().flatten().filter(|user| user["uid"] == 0 || user["system"] != true).filter_map(|user| user["username"].as_str().map(str::to_string)).collect();
658 if payload["dryRun"] == true {
659 let mut accounts = Vec::new();
660 for name in &names {
661 let detail = self.detail(session_id, name, &auth).await?;
662 let keys: usize = detail.public["keys"]["files"].as_array().into_iter().flatten().map(|file| file["entries"].as_array().map_or(0, Vec::len)).sum();
663 accounts.push(json!({ "username": name, "keys": keys, "alreadyOff": inventory.policies.get(name).is_some_and(|policy| policy.password == "no") }));
664 }
665 return Ok(json!({ "accounts": accounts }));
666 }
667 if payload["confirm"] != true {
668 return Err(fail("USERS_CONFIRMATION_REQUIRED", "The change must be confirmed."));
669 }
670 let targets: Vec<(String, Option<Policy>)> = names.iter().map(|name| {
671 let mut policy = inventory.policies.get(name).cloned().unwrap_or_default();
672 policy.password = "no".into();
673 (name.clone(), Some(policy))
674 }).collect();
675 let credentials = payload.get("probeCredentials").filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({}));
676 let mut result = self.apply_policies(session_id, targets, &auth, &credentials, true).await?;
677 result["accounts"] = json!(names);
678 Ok(result)
679 }
681 fn account_args(action: &str, username: &str, payload: &Value) -> CoreResult<Vec<String>> {
682 let u = username.to_string();
683 Ok(match action {
684 "delete" => vec![u, if payload["removeHome"] == true { "1" } else { "0" }.into(), if payload["stopProcesses"] == true { "1" } else { "0" }.into()],
685 "shell" => vec![u, validate_shell(&payload["shell"])?],
686 "comment" => vec![u, validate_comment(&payload["comment"])?],
687 "home" => vec![u, validate_home(&payload["home"])?, if payload["moveHome"] == true { "1" } else { "0" }.into()],
688 "primary" | "group-add" | "group-remove" => vec![u, validate_name(&payload["group"], "USERS_GROUP_INVALID")?],
689 _ => vec![u],
690 })
691 }
693 fn assert_mutable(user: &Value, action: &str) -> CoreResult<()> {
694 match user["protection"].as_str() {
695 Some("root") => Err(fail("USERS_PROTECTED_USER", "The root account is protected and cannot be changed here.")),
696 Some("system") => Err(fail("USERS_PROTECTED_USER", "System accounts are protected and cannot be changed here.")),
697 Some("session") => Err(fail(
698 "USERS_PROTECTED_USER",
699 if action == "password" {
700 "Change the password of the active session account with passwd in the terminal; CH-J would otherwise invalidate the stored login."
701 } else {
702 "The account used by the active SSH session is protected against changes that could lock you out."
703 },
704 )),
705 _ => Ok(()),
706 }
707 }
709 fn assert_not_last_admin(inventory: &Value, user: &Value, action: &str, payload: &Value) -> CoreResult<()> {
710 if inventory["privilege"]["mode"] == "root" {
711 return Ok(());
712 }
713 if action == "shell" && is_login_shell(&js_string(&payload["shell"])) {
714 return Ok(());
715 }
716 let users = inventory["users"].as_array().cloned().unwrap_or_default();
717 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();
718 if functional.iter().any(|c| c["username"] == user["username"]) && functional.iter().all(|c| c["username"] == user["username"]) {
719 return Err(fail("USERS_LAST_ADMIN", "This is the last working administrator account; the change was refused."));
720 }
721 Ok(())
722 }
724 async fn create(&self, session_id: &str, payload: &Value, sudo: Option<String>) -> CoreResult<Value> {
725 let username = validate_name(&payload["username"], "USERS_NAME_INVALID")?;
726 let options = json!({ "sudoPassword": sudo, "privileged": true });
727 let inventory = self.inventory(session_id, &options).await?;
728 if inventory.public["users"].as_array().is_some_and(|u| u.iter().any(|c| c["username"] == username.as_str())) {
729 return Err(fail("USERS_USER_EXISTS", "The account already exists."));
730 }
731 let shells = inventory.public["shells"].as_array().cloned().unwrap_or_default();
732 let fallback = if shells.iter().any(|s| s == "/bin/bash") { "/bin/bash" } else { "/bin/sh" };
733 let fallback_value = json!(fallback);
734 let shell = validate_shell(if truthy(&payload["shell"]) { &payload["shell"] } else { &fallback_value })?;
735 let home = if truthy(&payload["home"]) { validate_home(&payload["home"])? } else { String::new() };
736 let primary = if truthy(&payload["primaryGroup"]) { validate_name(&payload["primaryGroup"], "USERS_GROUP_INVALID")? } else { String::new() };
737 let mut group_list: Vec<String> = Vec::new();
738 for group in payload["groups"].as_array().cloned().unwrap_or_default() {
739 let name = validate_name(&group, "USERS_GROUP_INVALID")?;
740 if !group_list.contains(&name) {
741 group_list.push(name);
742 }
743 }
744 if group_list.len() > 32 {
745 return Err(fail("USERS_GROUP_INVALID", "Too many groups."));
746 }
747 let empty = json!("");
748 let comment = validate_comment(if truthy(&payload["comment"]) { &payload["comment"] } else { &empty })?;
749 let with_password = !matches!(&payload["password"], Value::Null) && payload["password"] != "";
750 let frames = if with_password { vec![Frame::text("SECRET", &validate_account_password(&payload["password"])?)] } else { vec![] };
751 let args = vec![
752 username.clone(),
753 shell,
754 home,
755 if payload["createHome"] == false { "0" } else { "1" }.into(),
756 primary,
757 group_list.join(","),
758 comment,
759 if payload["admin"] == true { "1" } else { "0" }.into(),
760 if with_password { "1" } else { "0" }.into(),
761 ];
762 let auth = Auth { privileged: true, sudo_password: sudo };
763 let run = self.run(session_id, args, frames, &auth, "users.create", 60_000).await?;
764 let outcome = parse_step_markers(&run.stdout);
765 self.logger.warn("Remote account created.", json!({ "sessionId": session_id, "username": username, "result": outcome.result }));
766 let refreshed = self.inventory(session_id, &options).await?;
767 let created = refreshed.public["users"].as_array().and_then(|u| u.iter().find(|c| c["username"] == username.as_str())).cloned();
768 let mut warnings: Vec<String> = Vec::new();
769 if created.is_none() {
770 warnings.push("The server did not list the new account after creation.".into());
771 }
772 if payload["admin"] == true && created.as_ref().is_some_and(|c| !c["sudo"].as_str().unwrap_or("").starts_with("full")) {
773 warnings.push("sudo -l does not report full sudo rights for the new account.".into());
774 }
775 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"] }))
776 }
778 /// Stages, verifies, reloads and confirms one change of the CH-J managed sshd drop-in.
779 /// With `target_exists = false` (cleanup after deletion) the session account supplies the SSH context.
780 async fn apply_policy_change(&self, session_id: &str, username: &str, policy: Option<Policy>, auth: &Auth, credentials: &Value, target_exists: bool) -> CoreResult<Value> {
781 self.apply_policies(session_id, vec![(username.to_string(), policy)], auth, credentials, target_exists).await
782 }
784 /// One managed file, one reload, one verification: several accounts change together or not at all.
785 async fn apply_policies(&self, session_id: &str, targets: Vec<(String, Option<Policy>)>, auth: &Auth, credentials: &Value, target_exists: bool) -> CoreResult<Value> {
786 let privilege = self.executor.probe(session_id, false).await?;
787 let session = self.sessions.session_auth_info(session_id)?;
788 let session_user = privilege["username"].as_str().unwrap_or("").to_string();
789 let username = targets.iter().map(|(name, _)| name.as_str()).find(|name| *name == session_user).or_else(|| targets.first().map(|(name, _)| name.as_str())).unwrap_or_default().to_string();
790 let username = username.as_str();
791 let detail = self.detail(session_id, if target_exists { username } else { &session_user }, &Auth { privileged: true, sudo_password: auth.sudo_password.clone() }).await?;
792 let ssh = &detail.public["ssh"];
793 if target_exists && detail.public["account"]["system"] == true {
794 return Err(fail("USERS_PROTECTED_USER", "SSH access of system accounts is not managed by CH-J."));
795 }
796 if ssh["available"] != true {
797 return Err(fail("USERS_SSHD_MISSING", "OpenSSH server was not found."));
798 }
799 if ssh["layoutSupported"] != true {
800 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."));
801 }
802 if ssh["syntaxOk"] == false {
803 return Err(fail("USERS_SSH_CONFIG_ALREADY_INVALID", &format!("The current SSH configuration is invalid: {}", ssh["syntaxMessage"].as_str().unwrap_or(""))));
804 }
805 let managed = &detail.managed;
806 if managed.state == "unsafe" || managed.state == "unreadable" {
807 return Err(fail("USERS_SSH_MANAGED_FILE_UNSAFE", "The CH-J managed SSH file is not a readable regular file."));
808 }
809 if !managed.consistent {
810 return Err(fail("USERS_SSH_MANAGED_FILE_MODIFIED", &format!("{MANAGED_PATH} was edited manually; CH-J will not overwrite it.")));
811 }
812 let mut policies = managed.policies.clone();
813 for (name, policy) in &targets {
814 match policy {
815 Some(p) if !p.is_default() => {
816 policies.insert(name.clone(), p.clone());
817 }
818 _ => {
819 policies.remove(name);
820 }
821 }
822 }
823 let content = policy::generate_managed_config(&policies);
824 let result_policy = targets.iter().find(|(name, _)| name == username).and_then(|(_, policy)| policy.clone()).unwrap_or_default().to_value();
825 if content == managed.content {
826 return Ok(json!({ "changed": false, "applied": false, "policy": result_policy }));
827 }
828 let self_account = targets.iter().any(|(name, _)| *name == session_user);
829 let method = if session["authMethod"] == "privateKey" { "publickey" } else { "password" };
830 let client_address = privilege["clientAddress"].as_str().unwrap_or("").to_string();
831 if client_address.parse::<std::net::IpAddr>().is_err() {
832 return Err(fail("USERS_SSH_CLIENT_UNKNOWN", "The client address of the SSH session is unknown, so lockout protection cannot be verified."));
833 }
834 if self_account {
835 let requirements = self.sessions.probe_login_requirements(session_id, credentials)?;
836 if requirements["ready"] != true {
837 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(""))));
838 }
839 }
840 let addresses = detail.addresses.clone();
841 let session_tuple = format!("{session_user}|{client_address}");
842 let mut tuples: Vec<String> = Vec::new();
843 if target_exists {
844 for (name, _) in &targets {
845 tuples.push(format!("{name}|{}", addresses.ipv4));
846 tuples.push(format!("{name}|{}", addresses.ipv6));
847 }
848 }
849 tuples.push(session_tuple.clone());
850 let mut seen = std::collections::HashSet::new();
851 tuples.retain(|t| seen.insert(t.clone()));
852 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() };
853 let mut args = vec![managed.sha256.clone().unwrap_or_else(|| "absent".into()), SSH_WATCHDOG_SECONDS.to_string()];
854 args.extend(tuples);
855 let options = self.options(args, vec![Frame::text("DATA", &content)], &Auth { privileged: true, sudo_password: auth.sudo_password.clone() }, "users.ssh-policy.apply", (SSH_WATCHDOG_SECONDS + 60) * 1000);
856 let sessions = self.sessions.clone();
857 let session_id_owned = session_id.to_string();
858 let username_owned = username.to_string();
859 let credentials_owned = credentials.clone();
860 let targets_for_check = targets.clone();
861 let interact = move |io: Interaction| async move {
862 static STAGED: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_STAGED\r?\n").unwrap());
863 static ROLLED_BACK: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_ROLLED_BACK=[^\n]*\n").unwrap());
864 static RELOADED: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_RELOADED=([^\n]*)\n").unwrap());
865 static CONFIRMED: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_CONFIRMED=([^\n]*)\n").unwrap());
866 let staged = io.wait_for(&STAGED, Duration::from_secs(60)).await?;
867 let before = parse_effective_blocks(&staged.before, "BEFORE");
868 let after = parse_effective_blocks(&staged.before, "AFTER");
869 let mut problems: Vec<String> = Vec::new();
870 for (name, policy) in &targets_for_check {
871 let (Some(policy), true) = (policy, target_exists) else { continue };
872 for (family, label) in [("ipv4", "IPv4"), ("ipv6", "IPv6")] {
873 match after.get(&format!("{name}|{}", addresses.get(family))) {
874 Some(effective) if effective.errors.is_empty() => problems.extend(policy::verify_policy_effect(policy, name, effective, label, &Default::default())),
875 _ => problems.push(format!("{label}: sshd -T did not return a usable result")),
876 }
877 }
878 }
879 let context = AccessContext { username: &session_user, groups: &session_groups, address: &client_address, method: Some(method), keyboard_interactive: true };
880 let lockout = match after.get(&session_tuple) {
881 Some(after_session) if after_session.errors.is_empty() => {
882 if self_account {
883 let evaluation = policy::evaluate_access(after_session, &AccessContext { username: &username_owned, groups: &session_groups, address: &client_address, method: Some(method), keyboard_interactive: true });
884 (evaluation.method_allowed != Some(true)).then(|| format!("The active session ({method} from {client_address}) would no longer be allowed to log in."))
885 } else {
886 // DenyUsers lines of other accounts appear in every sshd -T dump, so compare the evaluated result.
887 match before.get(&session_tuple) {
888 Some(before_session) if comparable_access(before_session, &context) == comparable_access(after_session, &context) => None,
889 _ => Some("The change would alter the effective SSH access of the active session account.".to_string()),
890 }
891 }
892 }
893 _ => Some("sshd -T could not evaluate the active session account.".to_string()),
894 };
895 if !problems.is_empty() || lockout.is_some() {
896 io.send("ROLLBACK");
897 io.wait_for(&ROLLED_BACK, Duration::from_secs(30)).await?;
898 return Ok(json!({ "rolledBack": true, "problems": problems, "lockout": lockout }));
899 }
900 io.send("COMMIT");
901 let reloaded = io.wait_for(&RELOADED, Duration::from_secs(60)).await?;
902 if self_account {
903 if let Err(error) = sessions.probe_login(&session_id_owned, &credentials_owned).await {
904 io.send("ROLLBACK");
905 io.wait_for(&ROLLED_BACK, Duration::from_secs(60)).await?;
906 return Ok(json!({ "rolledBack": true, "problems": [], "lockout": null, "probeError": error.message }));
907 }
908 }
909 io.send("CONFIRM");
910 let confirmed = io.wait_for(&CONFIRMED, Duration::from_secs(30)).await?;
911 let group = |m: &crate::core::ssh::privileged::Matched| m.groups.get(1).cloned().flatten().unwrap_or_default();
912 Ok(json!({ "rolledBack": false, "reload": clean_text(&group(&reloaded), 200), "backup": clean_text(&group(&confirmed), 300), "probed": self_account }))
913 };
914 let (_, outcome) = self.executor.run_interactive(session_id, options, Some(interact)).await?;
915 let outcome = outcome.unwrap_or_else(|| json!({ "rolledBack": true, "problems": ["no result"], "lockout": null }));
916 if outcome["rolledBack"] == true {
917 let reason = if !outcome["lockout"].is_null() { "lockout" } else if outcome.get("probeError").is_some() { "probe" } else { "not-effective" };
918 self.logger.warn("SSH policy change rolled back.", json!({ "sessionId": session_id, "username": username, "reason": reason }));
919 if let Some(lockout) = outcome["lockout"].as_str() {
920 return Err(fail("USERS_SSH_LOCKOUT_PREVENTED", &format!("{lockout} The previous configuration was restored.")));
921 }
922 if let Some(probe) = outcome.get("probeError").and_then(Value::as_str) {
923 return Err(fail("USERS_SSH_PROBE_FAILED", &format!("{probe} The previous SSH configuration was restored and reloaded.")));
924 }
925 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();
926 return Err(fail("USERS_SSH_POLICY_NOT_EFFECTIVE", &format!("Existing sshd rules take precedence ({}). The previous configuration was restored.", problems.join("; "))));
927 }
928 self.logger.warn("SSH policy applied and confirmed.", json!({ "sessionId": session_id, "username": username, "probed": outcome["probed"] }));
929 Ok(json!({ "changed": true, "applied": true, "policy": result_policy, "reload": outcome["reload"], "backup": outcome["backup"], "probed": outcome["probed"] }))
930 }
933/// authorized_keys file reported by `remoteUsers.sh detail`.
934pub struct KeyFile {
935 pub path: String,
936 pub spec: String,
937 pub kind: String,
938 pub meta: String,
939 pub dir_meta: String,
940 pub manageable: bool,
941 pub reason: Option<&'static str>,
942 pub entries: Vec<keys::KeyEntry>,
943 pub raw: Vec<u8>,
946impl KeyFile {
947 pub fn public(&self) -> Value {
948 json!({
949 "path": self.path,
950 "spec": self.spec,
951 "type": self.kind,
952 "meta": self.meta,
953 "dirMeta": self.dir_meta,
954 "manageable": self.manageable,
955 "reason": self.reason,
956 "entries": self.entries.iter().map(keys::KeyEntry::public).collect::<Vec<_>>()
957 })
958 }
961static TRAVERSAL: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)").unwrap());
963fn parse_key_files(text: &str, home: &str) -> Vec<KeyFile> {
964 let mut files = Vec::new();
965 for block in text.split("\x1eCHJ_KEYFILE=").skip(1) {
966 let mut block_lines = block.split('\n');
967 let first = block_lines.next().unwrap_or("");
968 let path_line = first.strip_suffix('\r').unwrap_or(first);
969 let values = key_values(&block_lines.collect::<Vec<_>>().join("\n"));
970 let path = clean_text(path_line, 300);
971 let raw = values.get("data").map(|d| B64.decode(d).unwrap_or_default()).unwrap_or_default();
972 let inside_home = !home.is_empty() && home != "/" && path.starts_with(&format!("{home}/")) && !TRAVERSAL.is_match(&path);
973 let kind = values.get("type").cloned().unwrap_or_else(|| "unknown".into());
974 let reason: Option<&'static str> = if !inside_home {
975 Some("outside-home")
976 } else {
977 match kind.as_str() {
978 "symlink" => Some("symlink"),
979 "other" => Some("not-regular"),
980 "unreadable" => Some("unreadable"),
981 "unsupported" => Some("unsupported-token"),
982 _ if values.get("tooLarge").map(String::as_str) == Some("1") => Some("too-large"),
983 "file" | "absent" | "absent-dir" => None,
984 _ => Some("unknown"),
985 }
986 };
987 files.push(KeyFile {
988 path,
989 spec: clean_text(values.get("spec").map(String::as_str).unwrap_or(""), 200),
990 entries: if kind == "file" { keys::parse_authorized_keys(&raw) } else { Vec::new() },
991 meta: clean_text(values.get("meta").map(String::as_str).unwrap_or(""), 120),
992 dir_meta: clean_text(values.get("dirMeta").map(String::as_str).unwrap_or(""), 120),
993 manageable: reason.is_none(),
994 reason,
995 kind,
996 raw,
997 });
998 }
999 files
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1006 #[test]
1007 fn parsers() {
1008 let out = parse_step_markers("x\x1eCHJ_STEP=useradd|ok|created\n\x1eCHJ_SUDO=full-all\n\x1eCHJ_RESULT=ok\n");
1009 assert_eq!(out.result, "ok");
1010 assert_eq!(out.sudo.as_deref(), Some("full-all"));
1011 assert_eq!(out.steps[0]["message"], "created");
1012 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");
1013 assert_eq!(blocks["bob|1.2.3.4"].get("passwordauthentication"), Some("no"));
1014 assert!(validate_home(&json!("/etc/x")).is_err());
1015 assert_eq!(validate_home(&json!("/var/lib/app/")).unwrap(), "/var/lib/app");
1016 assert!(validate_home(&json!("/var")).is_err());
1017 assert!(validate_shell(&json!("/bin/../bin/sh")).is_err());
1018 assert!(validate_comment(&json!("a:b")).is_err());
1019 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");
1020 assert!(files[0].manageable, "{:?} {:?} {}", files[0].reason, files[0].path, files[0].kind);
1021 assert_eq!(files[1].reason, Some("outside-home"));
1025#[cfg(test)]
1026#[path = "../../../tests/support/users_safety.rs"]
1027mod safety_tests;
SHA-256d28cb25e51680b20d46ef64aea7649e2b527e8b42fc7486c4e1eba7af1f320e9

CH-J Proprietary Software License 1.14 · Lähde ilmoitetaan CH-J Proprietary Software License 1.14-koodilla. Sen saatavuus ei muuta lisenssiehtoja eikä myönnä lisäoikeuksia.

Arkisto SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Tiedostot