CH-J Toolsspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Tools

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / Server Manager 872 souborů
core-20261030T220507Z-61e4558d889f Přidáno: · Sestaveno:
windows.rs Přidáno: Datum není evidováno 49,6 KB · 859 řádků
Kód Stáhnout soubor
1//! Windows user administration (port of `users/windowsUserAdminService.js`).
2//!
3//! Every operation runs the fixed `remoteUsers.ps1` through the Windows executor. Built-in
4//! accounts, domain/cloud identities and the session account are protected. sshd_config
5//! changes are staged (`sshd -t`, `sshd -T -f candidate`), committed through a SYSTEM
6//! scheduled task that arms the rollback before restarting sshd, and kept only after a NEW
7//! SSH login confirmed them.
9use super::common::*;
10use super::keys;
11use super::policy::{self, AccessContext, Effective, Policies, Policy, VerifyOptions};
12use super::windows_policy::{self as winpolicy, ManagedPolicies};
13use crate::core::error::CoreResult;
14use crate::core::logging::Logger;
15use crate::core::ssh::session::SessionManager;
16use crate::core::ssh::windows_exec::{bootstrap_input, encoded_command, parse_windows_output, WindowsExecutor, BOOTSTRAP};
17use base64::engine::general_purpose::STANDARD as B64;
18use base64::Engine;
19use once_cell::sync::Lazy;
20use rand::RngCore;
21use regex::Regex;
22use serde_json::{json, Map, Value};
23use std::sync::Arc;
24use std::time::Duration;
26pub const REMOTE_SCRIPT: &str = include_str!("../../../resources/remote/windows/remoteUsers.ps1");
27pub const ADMINS_SID: &str = "S-1-5-32-544";
28pub const APPLY_TIMEOUT_SECONDS: u64 = 120;
29static LOCAL_SID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^S-1-5-21-\d+-\d+-\d+-\d+$").unwrap());
30static GROUP_SID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$").unwrap());
31static NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$").unwrap());
33fn 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 })
48pub 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)
56pub 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)
64fn 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)
72fn 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)
80pub 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 })
102/// The account name used in sshd_config and in sshd -T for a local user ("codex", not "HOST\\codex").
103fn account_name(token_name: &str) -> String {
104 token_name.rsplit('\\').next().unwrap_or("").to_string()
107fn 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()
111/// Windows key file with internal raw bytes.
112pub 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>,
123pub 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,
137pub struct WindowsUsers {
138 pub sessions: Arc<SessionManager>,
139 pub executor: Arc<WindowsExecutor>,
140 pub logger: Arc<Logger>,
143impl 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_").await
146 }
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_users
175 .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_policy
209 })
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_PATH
420 },
421 "restartRequired": true,
422 "sshName": name,
423 "policy": policy.to_value(),
424 "effective": Value::Object(effective),
425 "warnings": warnings
426 },
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": groups
494 });
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": inventory
540 }))
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 restarting
633 /// 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 = self
680 .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 parsed
693 };
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 = self
735 .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 }
783static WRITE_RIGHTS: Lazy<Regex> = Lazy::new(|| Regex::new(r"Write|Modify|FullControl|ChangePermissions|TakeOwnership").unwrap());
785fn 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 None
808 };
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 }
841#[cfg(test)]
842mod 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 }
SHA-2560a157f448f0ec8c1bf107a6d7292c8cdd961c062cebeadbe799757df81e3bc3a

CH-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 archivu77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Soubory