Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
1704 souborů
0.1.2-beta
Přidáno: · Sestaveno:
1
//! Windows user administration (port of `users/windowsUserAdminService.js`).2
//!3
//! Every operation runs the fixed `remoteUsers.ps1` through the Windows executor. Built-in4
//! accounts, domain/cloud identities and the session account are protected. sshd_config5
//! changes are staged (`sshd -t`, `sshd -T -f candidate`), committed through a SYSTEM6
//! scheduled task that arms the rollback before restarting sshd, and kept only after a NEW7
//! SSH login confirmed them.9
use super::common::*;10
use super::keys;11
use super::policy::{self, AccessContext, Effective, Policies, Policy, VerifyOptions};12
use super::windows_policy::{self as winpolicy, ManagedPolicies};13
use crate::core::error::CoreResult;14
use crate::core::logging::Logger;15
use crate::core::ssh::session::SessionManager;16
use crate::core::ssh::windows_exec::{bootstrap_input, encoded_command, parse_windows_output, WindowsExecutor, BOOTSTRAP};17
use base64::engine::general_purpose::STANDARD as B64;18
use base64::Engine;19
use once_cell::sync::Lazy;20
use rand::RngCore;21
use regex::Regex;22
use serde_json::{json, Map, Value};23
use std::sync::Arc;24
use std::time::Duration;26
const SCRIPT: &str = "windows/remoteUsers.ps1";28
/// Maps a logged purpose to its matrix operation; account actions are looked up in the matrix.29
fn 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
})42
}43
pub const ADMINS_SID: &str = "S-1-5-32-544";44
pub const APPLY_TIMEOUT_SECONDS: u64 = 120;45
static LOCAL_SID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^S-1-5-21-\d+-\d+-\d+-\d+$").unwrap());46
static GROUP_SID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$").unwrap());47
static NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$").unwrap());49
fn 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
})62
}64
pub 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)70
}72
pub 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)78
}80
fn 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)86
}88
fn 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)94
}96
pub 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
})116
}118
/// The account name used in sshd_config and in sshd -T for a local user ("codex", not "HOST\\codex").119
fn account_name(token_name: &str) -> String {120
token_name.rsplit('\\').next().unwrap_or("").to_string()121
}123
fn 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()125
}127
/// Windows key file with internal raw bytes.128
pub 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>,137
}139
pub 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,151
}153
pub struct WindowsUsers {154
pub sessions: Arc<SessionManager>,155
pub executor: Arc<WindowsExecutor>,156
pub logger: Arc<Logger>,157
}159
impl 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_").await162
}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_users191
.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_policy225
})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_PATH436
},437
"restartRequired": true,438
"sshName": name,439
"policy": policy.to_value(),440
"effective": Value::Object(effective),441
"warnings": warnings442
},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": groups509
});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": inventory555
}))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 restarting648
/// 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 = self695
.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
parsed708
};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 = self750
.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
}796
}798
static WRITE_RIGHTS: Lazy<Regex> = Lazy::new(|| Regex::new(r"Write|Modify|FullControl|ChangePermissions|TakeOwnership").unwrap());800
fn 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
None823
};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 }854
}856
#[cfg(test)]857
mod 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
}874
}SHA-256
c50d659049ee1cd509658b2a4d1744f6de6cbd9f5b4042da0ac096d684ed74edCH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f