CH-J Toolsการจัดการเซิร์ฟเวอร์ผ่าน SSH
เมนู
ซอร์สที่เผยแพร่

CH-J Tools

เรียกดูไดเรกทอรีและไฟล์ของรุ่นแอปพลิเคชันที่ระบุ

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

CH-J Proprietary Software License 1.14 · ซอร์สโค้ดนี้ให้ไว้ภายใต้ CH-J Proprietary Software License 1.14 การเปิดให้เข้าถึงไม่ได้เปลี่ยนแปลงเงื่อนไขของสัญญาอนุญาตหรือให้สิทธิ์เพิ่มเติม

SHA-256 ของไฟล์บีบอัดac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
ไฟล์