Mã nguồn đã công bố
Tải mã nguồn dạng ZIP
CH-J Tools
Duyệt thư mục và tệp của một bản phát hành ứng dụng cụ thể.
CH-J / Server Manager
872 tệp
core-20261030T220507Z-61e4558d889f
Ngày thêm: · Ngày dựng:
1
//! Linux user administration (port of `users/userAdminService.js`, Linux part).2
//!3
//! All remote work runs the fixed `remoteUsers.sh` through the PrivilegedExecutor. Account4
//! changes are refused for root, system accounts and the active session account; changes that5
//! 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 session7
//! account) and rolled back automatically otherwise.9
use super::common::*;10
use super::keys;11
use super::policy::{self, AccessContext, Effective, Policies, Policy, MANAGED_PATH};12
use crate::core::error::CoreResult;13
use crate::core::logging::Logger;14
use crate::core::ssh::privileged::{Frame, Interaction, PrivilegedExecutor, RunOptions, RunResult};15
use crate::core::ssh::session::SessionManager;16
use base64::engine::general_purpose::STANDARD as B64;17
use base64::Engine;18
use once_cell::sync::Lazy;19
use regex::Regex;20
use serde_json::{json, Map, Value};21
use std::collections::BTreeMap;22
use std::sync::Arc;23
use std::time::Duration;25
pub const REMOTE_SCRIPT: &str = include_str!("../../../resources/remote/remoteUsers.sh");26
const SSH_WATCHDOG_SECONDS: u64 = 120;27
const FORBIDDEN_HOMES: [&str; 14] = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"];28
const 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.30
const ADMIN_REDUCING: [&str; 5] = ["delete", "lock", "expire", "sudo-revoke", "shell"];32
fn 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
}45
}47
pub 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)53
}55
static SHELL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+-]{1,127}$").unwrap());56
static HOME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/@+-]{1,254}$").unwrap());57
static HOME_BAD: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)|//").unwrap());59
pub 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)65
}67
pub 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)76
}78
pub 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)84
}86
pub 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)92
}94
struct Managed {95
state: &'static str,96
sha256: Option<String>,97
content: String,98
policies: Policies,99
consistent: bool,100
}102
fn 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
}119
}121
struct Passwd {122
username: String,123
uid: Option<i64>,124
gid: i64,125
comment: String,126
home: String,127
shell: String,128
}130
fn 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
}141
}143
impl 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
map153
}154
}156
fn 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
map168
}170
fn 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
map177
}179
pub fn is_login_shell(shell: &str) -> bool {180
!shell.is_empty() && !shell.ends_with("/nologin") && !shell.ends_with("/false")181
}183
#[derive(Debug, Clone, Default)]184
pub struct StepOutcome {185
pub steps: Vec<Value>,186
pub result: String,187
pub sudo: Option<String>,188
}190
pub 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
}199
}201
/// `\x1eCHJ_<marker>=<key>\n<body>` blocks up to the next `\x1eCHJ_` or the end.202
pub 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
blocks218
}220
const ACCESS_LIST_KEYS: [&str; 4] = ["denyusers", "allowusers", "denygroups", "allowgroups"];222
fn 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()226
}228
/// Inventory with internal fields kept out of IPC.229
pub struct Inventory {230
pub public: Value,231
pub policies: Policies,232
}234
/// Detail with internal fields kept out of IPC.235
pub struct Detail {236
pub public: Value,237
pub key_files: Vec<KeyFile>,238
managed: Managed,239
addresses: Addresses,240
groups: Vec<String>,241
}243
pub struct LinuxUsers {244
pub sessions: Arc<SessionManager>,245
pub executor: Arc<PrivilegedExecutor>,246
pub logger: Arc<Logger>,247
}249
pub struct Auth {250
pub privileged: bool,251
pub sudo_password: Option<String>,252
}254
fn 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
}259
}261
impl 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)).await264
}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 the297
/// 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_PATH383
},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": warnings515
},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 = detail608
.key_files609
.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
}883
}885
/// authorized_keys file reported by `remoteUsers.sh detail`.886
pub 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>,896
}898
impl 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
}911
}913
static TRAVERSAL: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)").unwrap());915
fn 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
files952
}954
#[cfg(test)]955
mod 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
}975
}977
#[cfg(test)]978
#[path = "../../../tests/support/users_safety.rs"]979
mod safety_tests;SHA-256
ba26240b79b4fb75cd8efb8fdf13655300b252b9e04d163f7b23b7313d05b1b2CH-J Proprietary Software License 1.14 · Mã nguồn được cung cấp theo CH-J Proprietary Software License 1.14. Việc công khai mã nguồn không thay đổi các điều khoản giấy phép và không cấp thêm quyền nào.
SHA-256 của tệp nén
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df