Published source
Download source ZIP
CH-J Tools
Browse directories and files for a specific application release.
CH-J / Server Manager
1704 files
0.1.2-beta
Added: · Built:
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
const SSH_WATCHDOG_SECONDS: u64 = 120;26
const FORBIDDEN_HOMES: [&str; 14] = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"];27
const 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.29
const ADMIN_REDUCING: [&str; 5] = ["delete", "lock", "expire", "sudo-revoke", "shell"];31
fn 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
}44
}46
pub 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)52
}54
static SHELL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+-]{1,127}$").unwrap());55
static HOME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/@+-]{1,254}$").unwrap());56
static HOME_BAD: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)|//").unwrap());58
pub 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)64
}66
pub 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)75
}77
pub 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)83
}85
pub 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)91
}93
struct Managed {94
state: &'static str,95
sha256: Option<String>,96
content: String,97
policies: Policies,98
consistent: bool,99
}101
fn 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
}118
}120
struct Passwd {121
username: String,122
uid: Option<i64>,123
gid: i64,124
comment: String,125
home: String,126
shell: String,127
}129
fn 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
}140
}142
impl 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
map152
}153
}155
fn 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
map167
}169
fn 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
map176
}178
pub fn is_login_shell(shell: &str) -> bool {179
!shell.is_empty() && !shell.ends_with("/nologin") && !shell.ends_with("/false")180
}182
#[derive(Debug, Clone, Default)]183
pub struct StepOutcome {184
pub steps: Vec<Value>,185
pub result: String,186
pub sudo: Option<String>,187
}189
pub 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
}198
}200
/// `\x1eCHJ_<marker>=<key>\n<body>` blocks up to the next `\x1eCHJ_` or the end.201
pub 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
blocks217
}219
const ACCESS_LIST_KEYS: [&str; 4] = ["denyusers", "allowusers", "denygroups", "allowgroups"];221
fn 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()225
}227
/// Inventory with internal fields kept out of IPC.228
pub struct Inventory {229
pub public: Value,230
pub policies: Policies,231
}233
/// Detail with internal fields kept out of IPC.234
pub struct Detail {235
pub public: Value,236
pub key_files: Vec<KeyFile>,237
managed: Managed,238
addresses: Addresses,239
groups: Vec<String>,240
}242
pub struct LinuxUsers {243
pub sessions: Arc<SessionManager>,244
pub executor: Arc<PrivilegedExecutor>,245
pub logger: Arc<Logger>,246
}248
pub struct Auth {249
pub privileged: bool,250
pub sudo_password: Option<String>,251
}253
fn 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
}258
}260
impl 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)).await263
}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 the296
/// 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_PATH382
},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": warnings514
},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 = 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![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).await782
}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
}931
}933
/// authorized_keys file reported by `remoteUsers.sh detail`.934
pub 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>,944
}946
impl 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
}959
}961
static TRAVERSAL: Lazy<Regex> = Lazy::new(|| Regex::new(r"/\.\.?(/|$)").unwrap());963
fn 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
files1000
}1002
#[cfg(test)]1003
mod 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"));1022
}1023
}1025
#[cfg(test)]1026
#[path = "../../../tests/support/users_safety.rs"]1027
mod safety_tests;SHA-256
d28cb25e51680b20d46ef64aea7649e2b527e8b42fc7486c4e1eba7af1f320e9CH-J Proprietary Software License 1.14 · Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
Archive SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f