Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
872 souborů
core-20261030T220507Z-61e4558d889f
Přidáno: · Sestaveno:
1
//! Windows user administration (port of `users/windowsUserAdminService.js`).2
//!3
//! Every operation runs the fixed `remoteUsers.ps1` through the Windows executor. Built-in4
//! accounts, domain/cloud identities and the session account are protected. sshd_config5
//! changes are staged (`sshd -t`, `sshd -T -f candidate`), committed through a SYSTEM6
//! scheduled task that arms the rollback before restarting sshd, and kept only after a NEW7
//! SSH login confirmed them.9
use super::common::*;10
use super::keys;11
use super::policy::{self, AccessContext, Effective, Policies, Policy, VerifyOptions};12
use super::windows_policy::{self as winpolicy, ManagedPolicies};13
use crate::core::error::CoreResult;14
use crate::core::logging::Logger;15
use crate::core::ssh::session::SessionManager;16
use crate::core::ssh::windows_exec::{bootstrap_input, encoded_command, parse_windows_output, WindowsExecutor, BOOTSTRAP};17
use base64::engine::general_purpose::STANDARD as B64;18
use base64::Engine;19
use once_cell::sync::Lazy;20
use rand::RngCore;21
use regex::Regex;22
use serde_json::{json, Map, Value};23
use std::sync::Arc;24
use std::time::Duration;26
pub const REMOTE_SCRIPT: &str = include_str!("../../../resources/remote/windows/remoteUsers.ps1");27
pub const ADMINS_SID: &str = "S-1-5-32-544";28
pub const APPLY_TIMEOUT_SECONDS: u64 = 120;29
static LOCAL_SID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^S-1-5-21-\d+-\d+-\d+-\d+$").unwrap());30
static GROUP_SID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$").unwrap());31
static NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$").unwrap());33
fn action_name(action: &str) -> Option<&'static str> {34
Some(match action {35
"enable" | "unexpire" => "enable",36
"disable" | "expire" => "disable",37
"setPassword" => "password",38
"setFullName" | "setComment" => "fullName",39
"setDescription" => "description",40
"addGroup" => "group-add",41
"removeGroup" => "group-remove",42
"grantAdmin" => "admin-grant",43
"revokeAdmin" => "admin-revoke",44
_ => return None,45
})46
}48
pub fn validate_windows_name(value: &Value) -> CoreResult<String> {49
let name = js_string(value).trim().to_string();50
if !NAME.is_match(&name) || name.ends_with('.') || name.ends_with(' ') {51
return Err(fail("USERS_NAME_INVALID", "Windows account names may contain letters, digits, space, '.', '_' or '-' (max. 20) and must not end with a dot or space."));52
}53
Ok(name)54
}56
pub fn validate_sid(value: &Value, pattern: &Regex, code: &str) -> CoreResult<String> {57
let sid = js_string(value).trim().to_uppercase();58
if !pattern.is_match(&sid) {59
return Err(fail(code, "Invalid security identifier (SID)."));60
}61
Ok(sid)62
}64
fn validate_password(value: &Value) -> CoreResult<String> {65
let password = js_string(value);66
if password.is_empty() || password.encode_utf16().count() > 256 || password.contains(['\0', '\r', '\n']) {67
return Err(fail("USERS_PASSWORD_INVALID", "The password must contain 1-256 characters and no line breaks."));68
}69
Ok(password)70
}72
fn validate_text(value: &Value, limit: usize, code: &str) -> CoreResult<String> {73
let text = js_string(value).trim().to_string();74
if text.encode_utf16().count() > limit || text.chars().any(|c| (c as u32) < 0x20 || c as u32 == 0x7f) {75
return Err(fail(code, &format!("The text may contain up to {limit} characters without control characters.")));76
}77
Ok(text)78
}80
pub fn privilege_from_token(token: &Value, system: &Value) -> Value {81
let elevated = token["elevated"] == true;82
let admin_member = token["adminMember"] == true;83
let mode = if elevated { "windows-elevated" } else if admin_member { "windows-admin-unelevated" } else { "windows-standard" };84
let groups: Vec<String> = token["groupNames"].as_array().map(|g| g.iter().take(256).map(|n| js_string(n).to_lowercase()).collect()).unwrap_or_default();85
json!({86
"platform": "windows",87
"mode": mode,88
"verified": true,89
"username": js_string(&token["name"]),90
"sid": js_string(&token["sid"]),91
"integrity": token["integrity"].as_str().map(str::to_string).unwrap_or_else(|| if token["integrity"].is_null() { "unknown".into() } else { js_string(&token["integrity"]) }),92
"adminMember": admin_member,93
"elevated": elevated,94
"sudoInstalled": false,95
"groups": groups,96
"clientAddress": js_string(&system["sshClient"]).trim().split_whitespace().next().unwrap_or(""),97
"message": "",98
"checkedAt": now_iso()99
})100
}102
/// The account name used in sshd_config and in sshd -T for a local user ("codex", not "HOST\\codex").103
fn account_name(token_name: &str) -> String {104
token_name.rsplit('\\').next().unwrap_or("").to_string()105
}107
fn decode_config(ssh: &Value) -> String {108
ssh["configData"].as_str().filter(|s| !s.is_empty()).map(|d| String::from_utf8_lossy(&B64.decode(d).unwrap_or_default()).to_string()).unwrap_or_default()109
}111
/// Windows key file with internal raw bytes.112
pub struct WinKeyFile {113
pub path: String,114
pub kind: String,115
pub shared: bool,116
pub manageable: bool,117
pub entries: Vec<keys::KeyEntry>,118
pub raw: Vec<u8>,119
pub public: Value,120
pub acl_warnings: Vec<String>,121
}123
pub struct WinDetail {124
pub public: Value,125
pub key_files: Vec<WinKeyFile>,126
pub config_text: String,127
pub config_sha256: Value,128
pub managed: Option<ManagedPolicies>,129
pub addresses: Addresses,130
pub groups: Vec<String>,131
pub privilege: Value,132
pub sid: String,133
pub username: String,134
pub self_account: bool,135
}137
pub struct WindowsUsers {138
pub sessions: Arc<SessionManager>,139
pub executor: Arc<WindowsExecutor>,140
pub logger: Arc<Logger>,141
}143
impl WindowsUsers {144
async fn run(&self, session_id: &str, args: Value, purpose: &str, timeout_ms: u64) -> CoreResult<Value> {145
self.executor.run(session_id, REMOTE_SCRIPT, &args, Duration::from_millis(timeout_ms), purpose, "USERS_").await146
}148
pub async fn privileges(&self, session_id: &str) -> CoreResult<Value> {149
let probe = self.run(session_id, json!({ "op": "probe" }), "users.windows.probe", 60_000).await?;150
Ok(privilege_from_token(&probe["token"], &probe["system"]))151
}153
pub async fn inventory(&self, session_id: &str, platform: &Value) -> CoreResult<Value> {154
let raw = self.run(session_id, json!({ "op": "inventory" }), "users.windows.inventory", 120_000).await?;155
Ok(self.map_inventory(session_id, &raw, platform))156
}158
fn map_inventory(&self, session_id: &str, raw: &Value, platform: &Value) -> Value {159
let privilege = privilege_from_token(&raw["token"], &raw["system"]);160
let config = decode_config(&raw["ssh"]);161
let managed = (raw["ssh"]["configReadable"] == true).then(|| winpolicy::parse_managed_policies(&config));162
let raw_users = raw["users"].as_array().cloned().unwrap_or_default();163
let machine_prefix = raw_users.iter().find(|u| LOCAL_SID.is_match(u["sid"].as_str().unwrap_or(""))).and_then(|u| u["sid"].as_str()).map(|sid| sid.rsplit_once('-').map(|(p, _)| p.to_string()).unwrap_or_default()).unwrap_or_default();164
let groups: Vec<Value> = raw["groups"]165
.as_array()166
.cloned()167
.unwrap_or_default()168
.into_iter()169
.map(|g| {170
let members: Vec<Value> = g["members"].as_array().cloned().unwrap_or_default().into_iter().map(|m| json!({ "sid": m["sid"], "name": clean_text(&js_string(&m["name"]), 120), "source": m["source"], "objectClass": m["objectClass"] })).collect();171
json!({ "name": js_string(&g["name"]), "sid": js_string(&g["sid"]), "description": clean_text(&js_string(&g["description"]), 200), "members": members })172
})173
.collect();174
let mut users: Vec<Value> = raw_users175
.iter()176
.map(|user| {177
let sid = js_string(&user["sid"]);178
let member_of: Vec<&Value> = groups.iter().filter(|g| g["members"].as_array().is_some_and(|m| m.iter().any(|m| m["sid"] == sid.as_str()))).collect();179
let rid: i64 = sid.rsplit('-').next().and_then(|r| r.parse().ok()).unwrap_or(0);180
let builtin = rid < 1000;181
let name = winpolicy::ssh_name(&js_string(&user["name"]));182
let ssh_policy = match (&name, &managed) {183
(Some(n), Some(m)) => m.policies.get(n).map(Policy::to_value).unwrap_or(Value::Null),184
_ => Value::Null,185
};186
json!({187
"platform": "windows",188
"username": user["name"],189
"sid": sid,190
"rid": rid,191
"enabled": user["enabled"] == true,192
"fullName": clean_text(&js_string(&user["fullName"]), 256),193
"comment": clean_text(&js_string(&user["fullName"]), 256),194
"description": clean_text(&js_string(&user["description"]), 256),195
"source": user["source"],196
"lastLogon": user["lastLogon"],197
"passwordLastSet": user["passwordLastSet"],198
"passwordRequired": user["passwordRequired"] == true,199
"passwordExpires": user["passwordExpires"],200
"accountExpires": user["accountExpires"],201
"admin": member_of.iter().any(|g| g["sid"] == ADMINS_SID),202
"groups": member_of.iter().map(|g| g["name"].clone()).collect::<Vec<_>>(),203
"groupSids": member_of.iter().map(|g| g["sid"].clone()).collect::<Vec<_>>(),204
"system": builtin,205
"builtin": builtin,206
"protection": if builtin { json!("builtin") } else if sid == privilege["sid"].as_str().unwrap_or("") { json!("session") } else { Value::Null },207
"sshName": name,208
"sshPolicy": ssh_policy209
})210
})211
.collect();212
users.sort_by(|a, b| (a["system"] == true).cmp(&(b["system"] == true)).then_with(|| policy::locale_cmp(a["username"].as_str().unwrap_or(""), b["username"].as_str().unwrap_or(""))));213
// Members that are not local accounts of this machine (domain, Microsoft Entra, other machines) are read-only.214
let mut external: Vec<Value> = Vec::new();215
for group in &groups {216
for member in group["members"].as_array().cloned().unwrap_or_default() {217
let sid = js_string(&member["sid"]);218
if !sid.starts_with("S-1-5-21-") && !sid.starts_with("S-1-12-1-") {219
continue;220
}221
if !machine_prefix.is_empty() && sid.starts_with(&format!("{machine_prefix}-")) {222
continue;223
}224
match external.iter_mut().find(|e| e["sid"] == sid.as_str()) {225
Some(entry) => entry["groups"].as_array_mut().unwrap().push(group["name"].clone()),226
None => external.push(json!({ "sid": sid, "name": member["name"], "source": member["source"], "groups": [group["name"]] })),227
}228
}229
}230
let system = &raw["system"];231
let display = js_string(&system["displayVersion"]);232
json!({233
"platform": "windows",234
"sessionId": session_id,235
"privileged": privilege["elevated"],236
"privilege": privilege,237
"server": {238
"os": format!("{} ({}{})", js_string(&system["caption"]), js_string(&system["version"]), if display.is_empty() { String::new() } else { format!(", {display}") }),239
"osId": "windows",240
"installationType": system["installationType"],241
"serverCore": js_string(&system["installationType"]).to_lowercase().contains("server core"),242
"partOfDomain": system["partOfDomain"] == true,243
"domain": clean_text(&js_string(&system["domain"]), 120),244
"powershell": format!("{} {}", js_string(&system["psVersion"]), js_string(&system["psEdition"])),245
"is64BitProcess": system["is64BitProcess"] == true,246
"defaultShell": platform["defaultShell"].as_str().unwrap_or("cmd.exe"),247
"sshLayout": match &managed { Some(m) if m.analysis.supported => "ok", Some(_) => "unsupported", None => "unreadable" },248
"sshVersion": clean_text(&js_string(&raw["ssh"]["version"]), 120),249
"sshService": clean_text(&js_string(&raw["ssh"]["service"]), 40),250
"managedPath": winpolicy::CONFIG_PATH,251
"tools": []252
},253
"shells": [],254
"groups": groups.iter().map(|g| json!({ "name": g["name"], "sid": g["sid"], "description": g["description"], "members": g["members"].as_array().map(|m| m.iter().map(|m| m["sid"].clone()).collect::<Vec<_>>()).unwrap_or_default() })).collect::<Vec<_>>(),255
"adminGroups": ["Administrators"],256
"externalIdentities": external,257
"managedPolicy": {258
"state": match &managed { Some(m) if !m.policies.is_empty() => "present", Some(_) => "absent", None => "unreadable" },259
"consistent": managed.as_ref().is_some_and(|m| m.consistent),260
"users": managed.as_ref().map(|m| m.policies.keys().cloned().collect::<Vec<_>>()).unwrap_or_default()261
},262
"users": users,263
"collectedAt": now_iso()264
})265
}267
fn resolve_target(payload: &Value) -> CoreResult<Value> {268
if truthy(&payload["sid"]) {269
return Ok(json!({ "sid": validate_sid(&payload["sid"], &LOCAL_SID, "USERS_SID_INVALID")? }));270
}271
let name = if payload.get("name").is_some_and(|n| !n.is_null()) { &payload["name"] } else { &payload["username"] };272
Ok(json!({ "name": validate_windows_name(name)? }))273
}275
pub async fn detail(&self, session_id: &str, payload: &Value, platform: &Value) -> CoreResult<WinDetail> {276
let target = Self::resolve_target(payload)?;277
let privilege = self.privileges(session_id).await?;278
let addresses = family_addresses(privilege["clientAddress"].as_str().unwrap_or(""));279
let mut args = json!({ "op": "detail", "addr4": addresses.ipv4, "addr6": addresses.ipv6 });280
for (k, v) in target.as_object().unwrap() {281
args[k] = v.clone();282
}283
let raw = self.run(session_id, args, "users.windows.detail", 120_000).await?;284
self.map_detail(session_id, &raw, privilege, addresses, platform)285
}287
fn map_detail(&self, session_id: &str, raw: &Value, privilege: Value, addresses: Addresses, platform: &Value) -> CoreResult<WinDetail> {288
let session = self.sessions.session_auth_info(session_id)?;289
let user = &raw["user"];290
let sid = js_string(&user["sid"]);291
let rid: i64 = sid.rsplit('-').next().and_then(|r| r.parse().ok()).unwrap_or(0);292
let groups: Vec<Value> = raw["groups"].as_array().cloned().unwrap_or_default().into_iter().map(|g| json!({ "sid": g["sid"], "name": js_string(&g["name"]) })).collect();293
let admin = groups.iter().any(|g| g["sid"] == ADMINS_SID);294
let name = winpolicy::ssh_name(&js_string(&user["name"]));295
let config = decode_config(&raw["ssh"]);296
let managed = (raw["ssh"]["configReadable"] == true).then(|| winpolicy::parse_managed_policies(&config));297
let policy = match (&name, &managed) {298
(Some(n), Some(m)) => m.policies.get(n).cloned().unwrap_or_default(),299
_ => Policy::default(),300
};301
let well_known = |sid: &str| match sid {302
"S-1-5-32-544" => Some("administrators"),303
"S-1-5-32-545" => Some("users"),304
"S-1-5-32-585" => Some("openssh users"),305
_ => None,306
};307
let mut group_names: Vec<String> = Vec::new();308
for g in &groups {309
for candidate in [Some(js_string(&g["name"]).to_lowercase()), well_known(g["sid"].as_str().unwrap_or("")).map(str::to_string)].into_iter().flatten() {310
if !candidate.is_empty() && !group_names.contains(&candidate) {311
group_names.push(candidate);312
}313
}314
}315
let mut effective = Map::new();316
let mut warnings: Vec<String> = Vec::new();317
let mut listener: Option<Value> = None;318
for (family, key) in [("ipv4", "effective4"), ("ipv6", "effective6")] {319
if raw[key].is_null() || name.is_none() {320
continue;321
}322
let entry = &raw[key];323
let mut parsed = policy::parse_effective(if entry["code"] == 0 { entry["text"].as_str().unwrap_or("") } else { "" });324
if entry["code"] != 0 {325
parsed.errors.push(clean_text(&js_string(&entry["text"]), 300));326
}327
let address = addresses.get(family);328
let evaluation = policy::evaluate_access(&parsed, &AccessContext { username: name.as_deref().unwrap_or(""), groups: &group_names, address: &address, method: None, keyboard_interactive: false });329
effective.insert(330
family.into(),331
json!({332
"address": address,333
"sample": addresses.sample(family),334
"passwordAuthentication": parsed.get("passwordauthentication").unwrap_or("unknown"),335
"pubkeyAuthentication": parsed.get("pubkeyauthentication").unwrap_or("unknown"),336
"authenticationMethods": parsed.get("authenticationmethods").unwrap_or("any"),337
"allowed": evaluation.allowed_value(),338
"reasons": evaluation.reasons,339
"methods": evaluation.methods,340
"errors": parsed.errors.iter().take(5).collect::<Vec<_>>()341
}),342
);343
if listener.is_none() {344
listener = Some(policy::listener_families(&parsed));345
}346
}347
if let Some(l) = &listener {348
effective.insert("listener".into(), l.clone());349
if l["ipv6"] == false {350
warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy.".into());351
}352
if l["ipv4"] == false {353
warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy.".into());354
}355
}356
if name.is_none() {357
warnings.push("The account name contains characters that cannot be used safely in sshd_config; CH-J does not manage its SSH policy.".into());358
}359
if let Some(m) = &managed {360
if !m.analysis.supported {361
warnings.push(format!("sshd_config cannot be edited safely: {}.", m.analysis.problems.join("; ")));362
} else if !m.consistent {363
warnings.push("The CH-J sections in sshd_config were edited manually; CH-J will not overwrite them.".into());364
}365
}366
let profile_path = raw["profilePath"].as_str().filter(|s| !s.is_empty()).map(str::to_string);367
let key_files: Vec<WinKeyFile> = raw["keyFiles"].as_array().cloned().unwrap_or_default().iter().map(|f| map_key_file(f, &sid, profile_path.as_deref())).collect();368
if key_files.iter().any(|f| f.shared) {369
warnings.push("Administrators share one administrators_authorized_keys file: every key in it can log in as any member of the Administrators group.".into());370
}371
for file in &key_files {372
warnings.extend(file.acl_warnings.iter().cloned());373
}374
let self_account = sid == privilege["sid"].as_str().unwrap_or("");375
let ssh = &raw["ssh"];376
let public = json!({377
"platform": "windows",378
"sessionId": session_id,379
"username": user["name"],380
"sid": sid,381
"privileged": privilege["elevated"],382
"self": self_account,383
"session": { "authMethod": session["authMethod"], "username": privilege["username"], "sid": privilege["sid"], "clientAddress": privilege["clientAddress"] },384
"privilege": privilege,385
"account": {386
"platform": "windows",387
"username": user["name"],388
"sid": sid,389
"rid": rid,390
"enabled": user["enabled"] == true,391
"fullName": clean_text(&js_string(&user["fullName"]), 256),392
"comment": clean_text(&js_string(&user["fullName"]), 256),393
"description": clean_text(&js_string(&user["description"]), 256),394
"source": user["source"],395
"lastLogon": user["lastLogon"],396
"passwordLastSet": user["passwordLastSet"],397
"passwordRequired": user["passwordRequired"] == true,398
"passwordExpires": user["passwordExpires"],399
"accountExpires": user["accountExpires"],400
"profilePath": profile_path,401
"groups": groups,402
"admin": admin,403
"system": rid < 1000,404
"protection": if rid < 1000 { json!("builtin") } else if self_account { json!("session") } else { Value::Null }405
},406
"ssh": {407
"available": ssh["installed"] == true,408
"version": clean_text(&js_string(&ssh["version"]), 160),409
"service": format!("{} / {}", ssh["service"].as_str().unwrap_or("unknown"), ssh["startType"].as_str().unwrap_or("unknown")),410
"configPath": ssh["configPath"],411
"layout": match &managed { Some(m) if m.analysis.supported => "ok", Some(_) => "unsupported", None => "unreadable" },412
"layoutSupported": managed.as_ref().is_some_and(|m| m.analysis.supported),413
"layoutProblems": managed.as_ref().map(|m| m.analysis.problems.clone()).unwrap_or_else(|| vec!["sshd_config is not readable".into()]),414
"syntaxOk": if raw["syntax"].is_object() { json!(raw["syntax"]["code"] == 0) } else { Value::Null },415
"syntaxMessage": clean_text(&js_string(&raw["syntax"]["output"]), 400),416
"managed": {417
"state": match &managed { Some(m) if !m.policies.is_empty() => "present", Some(_) => "absent", None => "unreadable" },418
"consistent": managed.as_ref().is_some_and(|m| m.consistent),419
"path": winpolicy::CONFIG_PATH420
},421
"restartRequired": true,422
"sshName": name,423
"policy": policy.to_value(),424
"effective": Value::Object(effective),425
"warnings": warnings426
},427
"keys": { "files": key_files.iter().map(|f| f.public.clone()).collect::<Vec<_>>(), "home": profile_path },428
"system": if platform.is_null() { Value::Null } else { platform.clone() },429
"collectedAt": now_iso()430
});431
Ok(WinDetail {432
public,433
key_files,434
config_text: config,435
config_sha256: ssh["configSha256"].clone(),436
managed,437
addresses,438
groups: group_names,439
privilege,440
sid,441
username: js_string(&user["name"]),442
self_account,443
})444
}446
fn assert_mutable(account: &Value) -> CoreResult<()> {447
match account["protection"].as_str() {448
Some("builtin") => return Err(fail("USERS_PROTECTED_USER", "Built-in Windows accounts (Administrator, Guest, DefaultAccount, WDAGUtilityAccount) are protected.")),449
Some("session") => return Err(fail("USERS_PROTECTED_USER", "The account used by the active SSH session is protected against changes that could lock you out.")),450
_ => {}451
}452
let source = js_string(&account["source"]);453
if !source.is_empty() && !["local", "microsoftaccount"].contains(&source.to_lowercase().as_str()) {454
return Err(fail("USERS_DOMAIN_READ_ONLY", "Domain and cloud identities are shown read-only; CH-J does not manage them."));455
}456
Ok(())457
}459
fn require_elevated(privilege: &Value) -> CoreResult<()> {460
if privilege["elevated"] == true {461
return Ok(());462
}463
Err(fail(464
"USERS_WINDOWS_ELEVATION_REQUIRED",465
if privilege["adminMember"] == true {466
"The SSH session belongs to Administrators, but its token is not elevated (UAC filtered). CH-J does not bypass UAC."467
} else {468
"The SSH account is not a local administrator; administrative operations are not available."469
},470
))471
}473
pub async fn manage(&self, session_id: &str, payload: &Value, platform: &Value) -> CoreResult<Value> {474
let privilege = self.privileges(session_id).await?;475
Self::require_elevated(&privilege)?;476
let requested = js_string(&payload["action"]);477
let result = if requested == "create" {478
let name = validate_windows_name(&payload["username"])?;479
let mut groups: Vec<String> = Vec::new();480
for sid in payload["groups"].as_array().cloned().unwrap_or_default() {481
let sid = validate_sid(&sid, &GROUP_SID, "USERS_GROUP_INVALID")?;482
if !groups.contains(&sid) {483
groups.push(sid);484
}485
}486
let full_name = if payload.get("fullName").is_some_and(|v| !v.is_null()) { &payload["fullName"] } else { &payload["comment"] };487
let mut args = json!({488
"op": "create",489
"name": name,490
"fullName": validate_text(full_name, 256, "USERS_COMMENT_INVALID")?,491
"description": validate_text(&payload["description"], 48, "USERS_COMMENT_INVALID")?,492
"admin": payload["admin"] == true,493
"groups": groups494
});495
if truthy(&payload["password"]) {496
args["password"] = json!(validate_password(&payload["password"])?);497
}498
self.run(session_id, args, "users.windows.create", 120_000).await?499
} else {500
let sid = validate_sid(&payload["sid"], &LOCAL_SID, "USERS_SID_INVALID")?;501
let inventory = self.inventory(session_id, platform).await?;502
let account = inventory["users"].as_array().and_then(|u| u.iter().find(|u| u["sid"] == sid.as_str())).cloned().ok_or_else(|| fail("USERS_USER_NOT_FOUND", "The local account does not exist."))?;503
Self::assert_mutable(&account)?;504
if requested == "delete" {505
self.run(session_id, json!({ "op": "delete", "sid": sid, "removeProfile": payload["removeProfile"] == true || payload["removeHome"] == true }), "users.windows.delete", 120_000).await?506
} else {507
let action = action_name(&requested).ok_or_else(|| fail("USERS_NOT_SUPPORTED_ON_PLATFORM", "This operation exists only on Linux (shell, home directory, primary group, password lock)."))?;508
let mut args = json!({ "op": "account", "action": action, "sid": sid });509
match action {510
"password" => args["password"] = json!(validate_password(&payload["password"])?),511
"fullName" => {512
let value = if payload.get("fullName").is_some_and(|v| !v.is_null()) { &payload["fullName"] } else { &payload["comment"] };513
args["value"] = json!(validate_text(value, 256, "USERS_COMMENT_INVALID")?);514
}515
"description" => args["value"] = json!(validate_text(&payload["description"], 48, "USERS_COMMENT_INVALID")?),516
"group-add" | "group-remove" => args["group"] = json!(validate_sid(&payload["group"], &GROUP_SID, "USERS_GROUP_INVALID")?),517
_ => {}518
}519
self.run(session_id, args, &format!("users.windows.{action}"), 120_000).await?520
}521
};522
self.logger.warn("Remote Windows account action completed.", json!({ "sessionId": session_id, "action": requested, "result": result["result"] }));523
let mut warnings: Vec<String> = Vec::new();524
if requested == "delete" && truthy(&payload["sid"]) {525
if let Err(error) = self.cleanup_policy(session_id, payload, &mut warnings).await {526
warnings.push(format!("CH-J SSH rules of the deleted account remain: {}", error.message));527
}528
}529
let inventory = self.inventory(session_id, platform).await?;530
let outcome = if !warnings.is_empty() && result["result"] == "ok" { json!("partial") } else { result["result"].clone() };531
Ok(json!({532
"action": requested,533
"username": js_string(&payload["username"]),534
"sid": if truthy(&result["sid"]) { result["sid"].clone() } else if truthy(&payload["sid"]) { payload["sid"].clone() } else { Value::Null },535
"result": outcome,536
"steps": result["steps"].as_array().cloned().unwrap_or_default(),537
"warnings": warnings,538
"users": inventory["users"],539
"inventory": inventory540
}))541
}543
async fn cleanup_policy(&self, session_id: &str, payload: &Value, warnings: &mut Vec<String>) -> CoreResult<()> {544
let Some(name) = winpolicy::ssh_name(&js_string(&payload["username"])) else { return Ok(()) };545
let session = self.privileges(session_id).await?;546
let context = self.detail(session_id, &json!({ "sid": session["sid"] }), &Value::Null).await?;547
if !context.managed.as_ref().is_some_and(|m| m.policies.contains_key(&name)) {548
return Ok(());549
}550
let credentials = payload.get("probeCredentials").filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({}));551
let result = self.apply_policy(session_id, &context, &name, None, &credentials, false).await?;552
warnings.extend(result["warnings"].as_array().cloned().unwrap_or_default().into_iter().filter_map(|w| w.as_str().map(str::to_string)));553
Ok(())554
}556
pub async fn keys(&self, session_id: &str, payload: &Value, platform: &Value) -> CoreResult<Value> {557
let action = js_string(&payload["action"]);558
if action != "add" && action != "remove" {559
return Err(fail("USERS_ACTION_INVALID", "Unsupported key action."));560
}561
let target = Self::resolve_target(payload)?;562
let detail = self.detail(session_id, &target, platform).await?;563
let session = self.sessions.session_auth_info(session_id)?;564
if detail.public["account"]["protection"] == "builtin" {565
return Err(fail("USERS_PROTECTED_USER", "Keys of built-in accounts are shown read-only."));566
}567
if !detail.self_account {568
Self::require_elevated(&detail.privilege)?;569
}570
let (file, content) = if action == "add" {571
let key = keys::normalize_public_key(&payload["publicKey"], payload.get("comment").unwrap_or(&Value::Null))?;572
for candidate in &detail.key_files {573
if candidate.entries.iter().any(|e| e.blob.as_deref() == Some(key.blob.as_str())) {574
return Err(fail("USERS_KEY_DUPLICATE", &format!("This public key is already authorized in {}.", candidate.path)));575
}576
}577
let file = detail.key_files.first().filter(|f| f.manageable).ok_or_else(|| {578
let reason = detail.key_files.first().and_then(|f| f.public["reason"].as_str().map(str::to_string)).unwrap_or_else(|| "not found".into());579
fail("USERS_KEYS_FILE_UNMANAGEABLE", &format!("The effective authorized_keys file cannot be managed safely ({reason})."))580
})?;581
(file, keys::append_key(&file.raw, &key)?)582
} else {583
let file = detail.key_files.iter().find(|f| Some(f.path.as_str()) == payload["path"].as_str()).filter(|f| f.manageable).ok_or_else(|| fail("USERS_KEYS_FILE_UNMANAGEABLE", "This key file cannot be changed safely."))?;584
let fingerprint = js_string(&payload["fingerprint"]);585
let session_uses_file = detail.self_account || (file.shared && detail.privilege["adminMember"] == true);586
if session_uses_file && session["authMethod"] == "privateKey" {587
if session["publicKeyFingerprint"].as_str().is_some_and(|fp| fp == fingerprint) {588
return Err(fail("USERS_KEY_IN_USE", "This key is used by the active CH-J session and is not removed."));589
}590
if file.entries.iter().filter(|e| e.valid).count() <= 1 {591
return Err(fail("USERS_KEY_LAST", "The last key of the file used by the active session is not removed."));592
}593
}594
(file, keys::remove_key(&file.raw, &payload["line"], &fingerprint)?)595
};596
let expected = if file.kind == "file" { keys::sha256_hex(&file.raw) } else { "absent".into() };597
self.run(598
session_id,599
json!({ "op": "keys-write", "sid": detail.sid, "path": file.path, "expected": expected, "data": B64.encode(&content), "addr4": detail.addresses.ipv4, "addr6": detail.addresses.ipv6 }),600
&format!("users.windows.keys.{action}"),601
120_000,602
)603
.await?;604
self.logger.warn("Remote Windows authorized keys changed.", json!({ "sessionId": session_id, "action": action, "path": file.path, "shared": file.shared }));605
let (path, shared) = (file.path.clone(), file.shared);606
let refreshed = self.detail(session_id, &json!({ "sid": detail.sid }), platform).await?;607
Ok(json!({ "action": action, "username": detail.username, "sid": detail.sid, "path": path, "shared": shared, "detail": refreshed.public }))608
}610
pub async fn ssh_policy(&self, session_id: &str, payload: &Value, platform: &Value) -> CoreResult<Value> {611
let target = Self::resolve_target(payload)?;612
let policy = policy::normalize_policy(payload.get("policy").filter(|p| p.is_object()).unwrap_or(&json!({})), Some("windows-account"))?;613
let detail = self.detail(session_id, &target, platform).await?;614
Self::require_elevated(&detail.privilege)?;615
if detail.public["account"]["protection"] == "builtin" {616
return Err(fail("USERS_PROTECTED_USER", "SSH access of built-in accounts is not managed by CH-J."));617
}618
let name = detail.public["ssh"]["sshName"].as_str().map(str::to_string).ok_or_else(|| fail("USERS_SSH_NAME_UNSUPPORTED", "The account name cannot be used safely in sshd_config."))?;619
let credentials = payload.get("probeCredentials").filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({}));620
let mut result = self.apply_policy(session_id, &detail, &name, Some(policy), &credentials, detail.self_account).await?;621
match self.detail(session_id, &json!({ "sid": detail.sid }), platform).await {622
Ok(refreshed) => result["detail"] = refreshed.public,623
Err(error) if error.code == "SESSION_NOT_CONNECTED" => {624
result["sessionLost"] = json!(true);625
result["detail"] = Value::Null;626
}627
Err(error) => return Err(error),628
}629
Ok(result)630
}632
/// Stage → commit through a SYSTEM scheduled task that arms the rollback before restarting633
/// sshd → confirm from a NEW SSH connection → otherwise the task restores the backup.634
async fn apply_policy(&self, session_id: &str, detail: &WinDetail, name: &str, policy: Option<Policy>, credentials: &Value, self_account: bool) -> CoreResult<Value> {635
let privilege = &detail.privilege;636
let ssh = &detail.public["ssh"];637
if ssh["available"] != true {638
return Err(fail("USERS_SSHD_MISSING", "OpenSSH Server (sshd.exe) is not installed."));639
}640
if ssh["syntaxOk"] == false {641
return Err(fail("USERS_SSH_CONFIG_ALREADY_INVALID", &format!("The current sshd_config is invalid: {}", ssh["syntaxMessage"].as_str().unwrap_or(""))));642
}643
let managed = detail.managed.as_ref().ok_or_else(|| fail("USERS_SSH_LAYOUT_UNSUPPORTED", "sshd_config is not readable."))?;644
let mut policies: Policies = managed.policies.clone();645
match &policy {646
Some(p) if !p.is_default() => {647
policies.insert(name.to_string(), p.clone());648
}649
_ => {650
policies.remove(name);651
}652
}653
let content = winpolicy::render_config(&detail.config_text, &policies)?;654
let result_policy = policy.clone().unwrap_or_default().to_value();655
if content == detail.config_text {656
return Ok(json!({ "changed": false, "applied": false, "policy": result_policy, "warnings": [] }));657
}658
let client_address = privilege["clientAddress"].as_str().unwrap_or("").to_string();659
if client_address.parse::<std::net::IpAddr>().is_err() {660
return Err(fail("USERS_SSH_CLIENT_UNKNOWN", "The client address of the SSH session is unknown, so lockout protection cannot be verified."));661
}662
// Every Windows change restarts sshd, so a fresh login is always required to keep it.663
let requirements = self.sessions.probe_login_requirements(session_id, credentials)?;664
if requirements["ready"] != true {665
return Err(fail("USERS_SSH_PROBE_CREDENTIALS_REQUIRED", &format!("Windows OpenSSH must be restarted; a test login ({}) is required to keep the change.", requirements["missing"].as_str().unwrap_or(""))));666
}667
let target = self.sessions.connection_target(session_id)?;668
let session = self.sessions.session_auth_info(session_id)?;669
let token_name = account_name(privilege["username"].as_str().unwrap_or(""));670
let session_name = winpolicy::ssh_name(&token_name).unwrap_or_else(|| token_name.to_lowercase());671
let method = if session["authMethod"] == "privateKey" { "publickey" } else { "password" };672
let addresses = &detail.addresses;673
let mut tuples: Vec<Value> = Vec::new();674
if policy.is_some() {675
tuples.push(json!({ "user": name, "addr": addresses.ipv4 }));676
tuples.push(json!({ "user": name, "addr": addresses.ipv6 }));677
}678
tuples.push(json!({ "user": session_name, "addr": client_address }));679
let staged = self680
.run(session_id, json!({ "op": "ssh-stage", "expected": detail.config_sha256, "data": B64.encode(content.as_bytes()), "tuples": tuples }), "users.windows.ssh-stage", 120_000)681
.await?;682
if staged["syntaxCode"] != 0 {683
let output = clean_text(&js_string(&staged["syntaxOutput"]), 400);684
return Err(fail("USERS_SSH_CONFIG_INVALID", if output.is_empty() { "sshd -t rejected the new configuration." } else { &output }));685
}686
let parse = |entry: &Value| -> Effective {687
let mut parsed = policy::parse_effective(if entry["code"] == 0 { entry["text"].as_str().unwrap_or("") } else { "" });688
if !entry.is_object() || entry["code"] != 0 {689
let text = js_string(&entry["text"]);690
parsed.errors.push(clean_text(if text.is_empty() { "missing" } else { &text }, 200));691
}692
parsed693
};694
let mut problems: Vec<String> = Vec::new();695
if let Some(policy) = &policy {696
for (family, label) in [("ipv4", "IPv4"), ("ipv6", "IPv6")] {697
let effective = parse(&staged["after"][format!("{name}|{}", addresses.get(family))]);698
if !effective.errors.is_empty() {699
problems.push(format!("{label}: sshd -T did not return a usable result"));700
} else {701
problems.extend(policy::verify_policy_effect(policy, name, &effective, label, &VerifyOptions { keyboard_interactive: false, ipv4_pattern: policy::WINDOWS_IPV4_PATTERN, ipv6_pattern: policy::WINDOWS_IPV6_PATTERN }));702
}703
// With UseDNS the host part is also matched against reverse-DNS names, where *.*.*.* is not IPv4-specific.704
if (policy.ipv4 == "deny" || policy.ipv6 == "deny") && effective.get("usedns") == Some("yes") {705
problems.push(format!("{label}: UseDNS yes makes address-family rules ambiguous"));706
}707
}708
}709
if !problems.is_empty() {710
return Err(fail("USERS_SSH_POLICY_NOT_EFFECTIVE", &format!("Existing sshd rules take precedence ({}). Nothing was changed.", problems.join("; "))));711
}712
let session_key = format!("{session_name}|{client_address}");713
let before = parse(&staged["before"][&session_key]);714
let after = parse(&staged["after"][&session_key]);715
let groups: Vec<String> = if detail.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() };716
let context = AccessContext { username: &session_name, groups: &groups, address: &client_address, method: Some(method), keyboard_interactive: false };717
let evaluation_after = policy::evaluate_access(&after, &context);718
if !after.errors.is_empty() || evaluation_after.method_allowed == Some(false) || evaluation_after.allowed == Some(false) {719
return Err(fail("USERS_SSH_LOCKOUT_PREVENTED", &format!("The active session ({method} from {client_address}) would no longer be allowed to log in. Nothing was changed.")));720
}721
if !self_account {722
let comparable = |effective: &Effective| {723
let evaluation = policy::evaluate_access(effective, &context);724
let settings: Vec<(&String, &Value)> = effective.values.iter().filter(|(k, _)| !["denyusers", "allowusers", "denygroups", "allowgroups"].contains(&k.as_str())).collect();725
serde_json::to_string(&json!({ "evaluation": { "allowed": evaluation.allowed_value(), "reasons": evaluation.reasons, "methods": evaluation.methods, "methodAllowed": evaluation.method_allowed }, "settings": settings })).unwrap_or_default()726
};727
if comparable(&before) != comparable(&after) {728
return Err(fail("USERS_SSH_LOCKOUT_PREVENTED", "The change would alter the effective SSH access of the active session account. Nothing was changed."));729
}730
}731
let mut nonce_bytes = [0u8; 24];732
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);733
let nonce = hex::encode(nonce_bytes);734
let commit = self735
.run(736
session_id,737
json!({ "op": "ssh-commit", "id": staged["id"], "nonce": nonce, "expected": detail.config_sha256, "candidateSha256": staged["candidateSha256"], "timeoutSeconds": APPLY_TIMEOUT_SECONDS }),738
"users.windows.ssh-commit",739
120_000,740
)741
.await?;742
self.logger.warn("Windows sshd change armed with rollback guard.", json!({ "sessionId": session_id, "id": staged["id"], "state": commit["state"] }));743
if commit["state"] != "awaiting-confirmation" {744
return Err(fail("USERS_SSH_ROLLED_BACK", &format!("The change was not kept ({}): {}", js_string(&commit["state"]), clean_text(&js_string(&commit["message"]), 300))));745
}746
let confirm_args = |confirm: bool, wait: u64| json!({ "op": "ssh-confirm", "id": staged["id"], "nonce": nonce, "confirm": confirm, "waitSeconds": wait });747
let confirmation = match self.sessions.fresh_exec(&target, credentials, &encoded_command(BOOTSTRAP), &bootstrap_input(REMOTE_SCRIPT, &confirm_args(true, 25)), Duration::from_secs(90), true).await {748
Ok(fresh) => parse_windows_output(fresh["stdout"].as_str().unwrap_or(""), fresh["stderr"].as_str().unwrap_or(""), fresh["code"].as_i64(), "USERS_")?,749
Err(error) => {750
let rollback = self.await_rollback(session_id, &target, credentials, confirm_args(false, APPLY_TIMEOUT_SECONDS + 30)).await;751
return Err(fail("USERS_SSH_PROBE_FAILED", &format!("{} The rollback guard reported: {rollback}", error.message)));752
}753
};754
if confirmation["state"] != "confirmed" {755
return Err(fail("USERS_SSH_ROLLED_BACK", &format!("The change was not kept ({}): {}", js_string(&confirmation["state"]), clean_text(&js_string(&confirmation["message"]), 300))));756
}757
self.logger.warn("Windows sshd change confirmed by a new SSH login.", json!({ "sessionId": session_id, "id": staged["id"] }));758
let mut warnings: Vec<String> = Vec::new();759
if self.sessions.require_connected(session_id).is_err() {760
warnings.push("The original SSH session was closed by the sshd restart. Reconnect in the main window.".into());761
}762
Ok(json!({ "changed": true, "applied": true, "probed": true, "restarted": true, "policy": result_policy, "applyId": staged["id"], "warnings": warnings }))763
}765
/// Waits for the server-side guard to restore the configuration, through the old session if it is still alive.766
async fn await_rollback(&self, session_id: &str, target: &crate::core::ssh::session::ConnectionTarget, credentials: &Value, args: Value) -> String {767
if self.sessions.require_connected(session_id).is_ok() {768
if let Ok(status) = self.executor.run(session_id, REMOTE_SCRIPT, &args, Duration::from_secs(APPLY_TIMEOUT_SECONDS + 60), "users.windows.ssh-confirm", "USERS_").await {769
return format!("{} ({})", js_string(&status["state"]), clean_text(&js_string(&status["message"]), 200));770
}771
}772
tokio::time::sleep(Duration::from_secs(APPLY_TIMEOUT_SECONDS + 15)).await;773
match self.sessions.fresh_exec(target, credentials, &encoded_command(BOOTSTRAP), &bootstrap_input(REMOTE_SCRIPT, &args), Duration::from_secs(APPLY_TIMEOUT_SECONDS + 60), true).await {774
Ok(fresh) => match parse_windows_output(fresh["stdout"].as_str().unwrap_or(""), fresh["stderr"].as_str().unwrap_or(""), fresh["code"].as_i64(), "USERS_") {775
Ok(status) => format!("{} ({})", js_string(&status["state"]), clean_text(&js_string(&status["message"]), 200)),776
Err(error) => format!("unknown ({}); the scheduled task restores the previous sshd_config automatically after {APPLY_TIMEOUT_SECONDS} s", clean_text(&error.message, 200)),777
},778
Err(error) => format!("unknown ({}); the scheduled task restores the previous sshd_config automatically after {APPLY_TIMEOUT_SECONDS} s", clean_text(&error.message, 200)),779
}780
}781
}783
static WRITE_RIGHTS: Lazy<Regex> = Lazy::new(|| Regex::new(r"Write|Modify|FullControl|ChangePermissions|TakeOwnership").unwrap());785
fn map_key_file(file: &Value, owner_sid: &str, profile_path: Option<&str>) -> WinKeyFile {786
let raw = file["data"].as_str().filter(|s| !s.is_empty()).map(|d| B64.decode(d).unwrap_or_default()).unwrap_or_default();787
let kind = js_string(&file["type"]);788
let shared = file["shared"] == true;789
let path = js_string(&file["path"]);790
let reason: Option<&str> = if path.is_empty() || kind == "unresolved" {791
Some("unsupported-token")792
} else if kind == "needs-elevation" {793
Some("needs-elevation")794
} else if file["reparse"] == true || kind == "reparse" {795
Some("symlink")796
} else if kind == "other" {797
Some("not-regular")798
} else if kind == "unreadable" {799
Some("unreadable")800
} else if file["tooLarge"] == true {801
Some("too-large")802
} else if kind == "absent-dir" && (shared || profile_path.is_none()) {803
Some("profile-missing")804
} else if !["file", "absent", "absent-dir"].contains(&kind.as_str()) {805
Some("unknown")806
} else {807
None808
};809
let mut allowed = vec!["S-1-5-18".to_string(), ADMINS_SID.to_string()];810
if !shared {811
allowed.push(owner_sid.to_string());812
}813
let acl: Vec<Value> = file["acl"].as_array().cloned().unwrap_or_default();814
let mut acl_warnings = Vec::new();815
for entry in &acl {816
let sid = js_string(&entry["sid"]);817
if entry["type"] == "Allow" && !allowed.contains(&sid) && WRITE_RIGHTS.is_match(&js_string(&entry["rights"])) {818
acl_warnings.push(format!("{path}: {sid} has {}; Windows OpenSSH ignores key files writable by other principals.", js_string(&entry["rights"])));819
}820
}821
let entries = if kind == "file" { keys::parse_authorized_keys(&raw) } else { Vec::new() };822
let owner = file["owner"].as_str().filter(|s| !s.is_empty()).map(str::to_string);823
let public = json!({824
"path": path,825
"spec": clean_text(&js_string(&file["spec"]), 200),826
"type": kind,827
"shared": shared,828
"owner": owner,829
"acl": acl.iter().map(|e| json!({ "sid": e["sid"], "rights": clean_text(&js_string(&e["rights"]), 120), "type": e["type"], "inherited": e["inherited"] == true })).collect::<Vec<_>>(),830
"meta": owner.as_ref().map(|o| format!("owner {o}")).unwrap_or_default(),831
"dirMeta": "",832
"sha256": file["sha256"].as_str().filter(|s| !s.is_empty()).unwrap_or("absent"),833
"manageable": reason.is_none(),834
"reason": reason,835
"aclWarnings": acl_warnings,836
"entries": entries.iter().map(keys::KeyEntry::public).collect::<Vec<_>>()837
});838
WinKeyFile { path, kind, shared, manageable: reason.is_none(), entries, raw, public, acl_warnings }839
}841
#[cfg(test)]842
mod tests {843
use super::*;845
#[test]846
fn token_and_names() {847
let privilege = privilege_from_token(&json!({ "name": "HOST\\Codex", "sid": "S-1-5-21-1-2-3-1001", "elevated": false, "adminMember": true, "groupNames": ["Administrators"] }), &json!({ "sshClient": "192.0.2.5 50000 22" }));848
assert_eq!(privilege["mode"], "windows-admin-unelevated");849
assert_eq!(privilege["clientAddress"], "192.0.2.5");850
assert_eq!(privilege["groups"][0], "administrators");851
assert_eq!(account_name("HOST\\codex"), "codex");852
assert!(validate_windows_name(&json!("Bad.")).is_err());853
assert!(validate_sid(&json!("s-1-5-21-1-2-3-1001"), &LOCAL_SID, "X").is_ok());854
assert!(validate_sid(&json!("S-1-5-32-544"), &LOCAL_SID, "X").is_err());855
let file = map_key_file(&json!({ "path": "C:\\Users\\a\\.ssh\\authorized_keys", "type": "file", "data": "", "acl": [{ "sid": "S-1-1-0", "rights": "Modify", "type": "Allow" }] }), "S-1-5-21-1-2-3-1001", Some("C:\\Users\\a"));856
assert!(file.manageable);857
assert_eq!(file.acl_warnings.len(), 1);858
}859
}SHA-256
0a157f448f0ec8c1bf107a6d7292c8cdd961c062cebeadbe799757df81e3bc3aCH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df