CH-J Server ManagerSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Server Manager

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 368 Dateien
core-20261017T201717Z-057348c4a70f Hinzugefügt: Datum nicht erfasst · Erstellt:
windowsUserAdminService.js Hinzugefügt: Datum nicht erfasst 32,5 KB · 536 Zeilen
Code Datei herunterladen
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const net = require("node:net");
6const path = require("node:path");
7const { SessionError } = require("../sessions/sessionManager");
8const { BOOTSTRAP, bootstrapInput, encodedCommand, parseWindowsOutput } = require("../sessions/windowsPrivilegedExec");
9const keys = require("./authorizedKeys");
10const policyTools = require("./sshPolicy");
11const windowsPolicy = require("./windowsSshPolicy");
13const REMOTE_SCRIPT = fs.readFileSync(path.join(__dirname, "windows", "remoteUsers.ps1"), "utf8");
14const ADMINS_SID = "S-1-5-32-544";
15const LOCAL_SID = /^S-1-5-21-\d+-\d+-\d+-\d+$/;
16const GROUP_SID = /^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$/;
17const NAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$/;
18const SAMPLE_IPV4 = "192.0.2.10";
19const SAMPLE_IPV6 = "2001:db8::10";
20const APPLY_TIMEOUT_SECONDS = 120;
21const FINAL_STATES = new Set(["confirmed", "rolled-back", "rollback-failed", "aborted"]);
22const ACTIONS = Object.freeze({
23 enable: "enable", disable: "disable", unexpire: "enable", expire: "disable",
24 setPassword: "password", setFullName: "fullName", setComment: "fullName", setDescription: "description",
25 addGroup: "group-add", removeGroup: "group-remove", grantAdmin: "admin-grant", revokeAdmin: "admin-revoke"
26});
28function fail(code, message) { return new SessionError(code, message); }
29function cleanText(value, limit = 300) { return String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit); }
31function validateWindowsName(value) {
32 const name = String(value ?? "").trim();
33 if (!NAME_PATTERN.test(name) || /[. ]$/.test(name)) throw fail("USERS_NAME_INVALID", "Windows account names may contain letters, digits, space, '.', '_' or '-' (max. 20) and must not end with a dot or space.");
34 return name;
36function validateSid(value, pattern = LOCAL_SID, code = "USERS_SID_INVALID") {
37 const sid = String(value ?? "").trim().toUpperCase();
38 if (!pattern.test(sid)) throw fail(code, "Invalid security identifier (SID).");
39 return sid;
41function validatePassword(value) {
42 const password = String(value ?? "");
43 if (!password || password.length > 256 || /[\0\r\n]/.test(password)) throw fail("USERS_PASSWORD_INVALID", "The password must contain 1-256 characters and no line breaks.");
44 return password;
46function validateText(value, limit, code) {
47 const text = String(value ?? "").trim();
48 if (text.length > limit || /[\x00-\x1f\x7f]/.test(text)) throw fail(code, `The text may contain up to ${limit} characters without control characters.`);
49 return text;
52function familyAddresses(clientAddress) {
53 const family = net.isIP(clientAddress);
54 return { ipv4: family === 4 ? clientAddress : SAMPLE_IPV4, ipv6: family === 6 ? clientAddress : SAMPLE_IPV6, ipv4Sample: family !== 4, ipv6Sample: family !== 6 };
57function privilegeFromToken(token, system) {
58 const mode = token.elevated ? "windows-elevated" : token.adminMember ? "windows-admin-unelevated" : "windows-standard";
59 return {
60 platform: "windows",
61 mode,
62 verified: true,
63 username: String(token.name || ""),
64 sid: String(token.sid || ""),
65 integrity: String(token.integrity || "unknown"),
66 adminMember: token.adminMember === true,
67 elevated: token.elevated === true,
68 sudoInstalled: false,
69 groups: Array.isArray(token.groupNames) ? token.groupNames.map((name) => String(name).toLowerCase()).slice(0, 256) : [],
70 clientAddress: String(system?.sshClient || "").trim().split(/\s+/)[0] || "",
71 message: "",
72 checkedAt: new Date().toISOString()
73 };
76// The account name used in sshd_config and in sshd -T for a local user ("codex", not "HOST\\codex").
77function accountName(tokenName) { return String(tokenName || "").split("\\").pop(); }
79function decodeConfig(ssh) {
80 return ssh?.configData ? Buffer.from(ssh.configData, "base64").toString("utf8") : "";
83class WindowsUserAdminService {
84 constructor(options) {
85 this.sessionManager = options.sessionManager;
86 this.executor = options.windowsExecutor;
87 this.logger = options.logger;
88 }
90 _run(sessionId, args, purpose, timeoutMs) {
91 return this.executor.run(sessionId, { script: REMOTE_SCRIPT, args, purpose, timeoutMs });
92 }
94 async privileges(sessionId) {
95 const probe = await this._run(sessionId, { op: "probe" }, "users.windows.probe", 60000);
96 return privilegeFromToken(probe.token, probe.system);
97 }
99 async inventory(sessionId, platform) {
100 const raw = await this._run(sessionId, { op: "inventory" }, "users.windows.inventory", 120000);
101 return this._mapInventory(sessionId, raw, platform);
102 }
104 _mapInventory(sessionId, raw, platform) {
105 const privilege = privilegeFromToken(raw.token, raw.system);
106 const config = decodeConfig(raw.ssh);
107 const managed = raw.ssh.configReadable ? windowsPolicy.parseManagedPolicies(config) : null;
108 const machinePrefix = (raw.users.find((user) => LOCAL_SID.test(user.sid))?.sid || "").replace(/-\d+$/, "");
109 const groups = raw.groups.map((group) => ({ name: String(group.name), sid: String(group.sid), description: cleanText(group.description, 200), members: group.members.map((member) => ({ sid: member.sid, name: cleanText(member.name, 120), source: member.source, objectClass: member.objectClass })) }));
110 const users = raw.users.map((user) => {
111 const memberOf = groups.filter((group) => group.members.some((member) => member.sid === user.sid));
112 const rid = Number(String(user.sid).split("-").pop());
113 const builtin = rid < 1000;
114 const name = windowsPolicy.sshName(user.name);
115 return {
116 platform: "windows",
117 username: user.name,
118 sid: user.sid,
119 rid,
120 enabled: user.enabled === true,
121 fullName: cleanText(user.fullName, 256),
122 comment: cleanText(user.fullName, 256),
123 description: cleanText(user.description, 256),
124 source: user.source,
125 lastLogon: user.lastLogon,
126 passwordLastSet: user.passwordLastSet,
127 passwordRequired: user.passwordRequired === true,
128 passwordExpires: user.passwordExpires,
129 accountExpires: user.accountExpires,
130 admin: memberOf.some((group) => group.sid === ADMINS_SID),
131 groups: memberOf.map((group) => group.name),
132 groupSids: memberOf.map((group) => group.sid),
133 system: builtin,
134 builtin,
135 protection: builtin ? "builtin" : user.sid === privilege.sid ? "session" : null,
136 sshName: name,
137 sshPolicy: name && managed ? managed.policies.get(name) || null : null
138 };
139 }).sort((left, right) => Number(left.system) - Number(right.system) || left.username.localeCompare(right.username));
140 // Members that are not local accounts of this machine (domain, Microsoft Entra, other machines) are read-only.
141 const external = new Map();
142 for (const group of groups) for (const member of group.members) {
143 if (!/^S-1-5-21-/.test(member.sid) && !/^S-1-12-1-/.test(member.sid)) continue;
144 if (machinePrefix && member.sid.startsWith(`${machinePrefix}-`)) continue;
145 const entry = external.get(member.sid) || { sid: member.sid, name: member.name, source: member.source, groups: [] };
146 entry.groups.push(group.name);
147 external.set(member.sid, entry);
148 }
149 return {
150 platform: "windows",
151 sessionId,
152 privileged: privilege.elevated,
153 privilege,
154 server: {
155 os: `${raw.system.caption} (${raw.system.version}${raw.system.displayVersion ? `, ${raw.system.displayVersion}` : ""})`,
156 osId: "windows",
157 installationType: raw.system.installationType,
158 serverCore: /server core/i.test(raw.system.installationType),
159 partOfDomain: raw.system.partOfDomain === true,
160 domain: cleanText(raw.system.domain, 120),
161 powershell: `${raw.system.psVersion} ${raw.system.psEdition}`,
162 is64BitProcess: raw.system.is64BitProcess === true,
163 defaultShell: platform?.defaultShell || "cmd.exe",
164 sshLayout: managed?.analysis.supported ? "ok" : managed ? "unsupported" : "unreadable",
165 sshVersion: cleanText(raw.ssh.version, 120),
166 sshService: cleanText(raw.ssh.service, 40),
167 managedPath: windowsPolicy.CONFIG_PATH,
168 tools: []
169 },
170 shells: [],
171 groups: groups.map(({ members, ...group }) => ({ ...group, members: members.map((member) => member.sid) })),
172 adminGroups: ["Administrators"],
173 externalIdentities: [...external.values()],
174 managedPolicy: { state: managed ? (managed.policies.size ? "present" : "absent") : "unreadable", consistent: managed ? managed.consistent : false, users: managed ? [...managed.policies.keys()] : [] },
175 users,
176 collectedAt: new Date().toISOString()
177 };
178 }
180 async _resolveTarget(sessionId, payload) {
181 if (payload.sid) return { sid: validateSid(payload.sid) };
182 return { name: validateWindowsName(payload.name ?? payload.username) };
183 }
185 async detail(sessionId, payload = {}, platform) {
186 const target = await this._resolveTarget(sessionId, payload);
187 const privilege = await this.privileges(sessionId);
188 const addresses = familyAddresses(privilege.clientAddress);
189 const raw = await this._run(sessionId, { op: "detail", ...target, addr4: addresses.ipv4, addr6: addresses.ipv6 }, "users.windows.detail", 120000);
190 return this._mapDetail(sessionId, raw, privilege, addresses, platform);
191 }
193 _mapDetail(sessionId, raw, privilege, addresses, platform) {
194 const session = this.sessionManager.sessionAuthInfo(sessionId);
195 const user = raw.user;
196 const rid = Number(String(user.sid).split("-").pop());
197 const groups = raw.groups.map((group) => ({ sid: group.sid, name: String(group.name) }));
198 const admin = groups.some((group) => group.sid === ADMINS_SID);
199 const name = windowsPolicy.sshName(user.name);
200 const config = decodeConfig(raw.ssh);
201 const managed = raw.ssh.configReadable ? windowsPolicy.parseManagedPolicies(config) : null;
202 const policy = (name && managed?.policies.get(name)) || { ...policyTools.POLICY_DEFAULTS };
203 const WELL_KNOWN = { "S-1-5-32-544": "administrators", "S-1-5-32-545": "users", "S-1-5-32-585": "openssh users" };
204 const groupNames = [...new Set(groups.flatMap((group) => [group.name.toLowerCase(), WELL_KNOWN[group.sid]].filter(Boolean)))];
205 const effective = {};
206 const warnings = [];
207 for (const [family, key] of [["ipv4", "effective4"], ["ipv6", "effective6"]]) {
208 if (!raw[key] || !name) continue;
209 const parsed = policyTools.parseEffective(raw[key].code === 0 ? raw[key].text : "");
210 if (raw[key].code !== 0) parsed.errors.push(cleanText(raw[key].text, 300));
211 const evaluation = policyTools.evaluateAccess(parsed, { username: name, groups: groupNames, address: addresses[family], keyboardInteractive: false });
212 effective[family] = {
213 address: addresses[family],
214 sample: addresses[`${family}Sample`],
215 passwordAuthentication: parsed.values.passwordauthentication || "unknown",
216 pubkeyAuthentication: parsed.values.pubkeyauthentication || "unknown",
217 authenticationMethods: parsed.values.authenticationmethods || "any",
218 allowed: evaluation.allowed,
219 reasons: evaluation.reasons,
220 methods: evaluation.methods,
221 errors: parsed.errors.slice(0, 5)
222 };
223 effective.listener ||= policyTools.listenerFamilies(parsed);
224 }
225 if (effective.listener && !effective.listener.ipv6) warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy.");
226 if (effective.listener && !effective.listener.ipv4) warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy.");
227 if (!name) warnings.push("The account name contains characters that cannot be used safely in sshd_config; CH-J does not manage its SSH policy.");
228 if (managed && !managed.analysis.supported) warnings.push(`sshd_config cannot be edited safely: ${managed.analysis.problems.join("; ")}.`);
229 if (managed && managed.analysis.supported && !managed.consistent) warnings.push("The CH-J sections in sshd_config were edited manually; CH-J will not overwrite them.");
230 const keyFiles = (raw.keyFiles || []).map((file) => this._mapKeyFile(file, user.sid, raw.profilePath));
231 if (keyFiles.some((file) => file.shared)) warnings.push("Administrators share one administrators_authorized_keys file: every key in it can log in as any member of the Administrators group.");
232 for (const file of keyFiles) for (const warning of file.aclWarnings) warnings.push(warning);
233 const self = user.sid === privilege.sid;
234 return {
235 platform: "windows",
236 sessionId,
237 username: user.name,
238 sid: user.sid,
239 privileged: privilege.elevated,
240 self,
241 session: { authMethod: session.authMethod, username: privilege.username, sid: privilege.sid, clientAddress: privilege.clientAddress },
242 privilege,
243 account: {
244 platform: "windows",
245 username: user.name,
246 sid: user.sid,
247 rid,
248 enabled: user.enabled === true,
249 fullName: cleanText(user.fullName, 256),
250 comment: cleanText(user.fullName, 256),
251 description: cleanText(user.description, 256),
252 source: user.source,
253 lastLogon: user.lastLogon,
254 passwordLastSet: user.passwordLastSet,
255 passwordRequired: user.passwordRequired === true,
256 passwordExpires: user.passwordExpires,
257 accountExpires: user.accountExpires,
258 profilePath: raw.profilePath || null,
259 groups,
260 admin,
261 system: rid < 1000,
262 protection: rid < 1000 ? "builtin" : self ? "session" : null
263 },
264 ssh: {
265 available: raw.ssh.installed === true,
266 version: cleanText(raw.ssh.version, 160),
267 service: `${raw.ssh.service || "unknown"} / ${raw.ssh.startType || "unknown"}`,
268 configPath: raw.ssh.configPath,
269 layout: managed?.analysis.supported ? "ok" : managed ? "unsupported" : "unreadable",
270 layoutSupported: Boolean(managed?.analysis.supported),
271 layoutProblems: managed?.analysis.problems || ["sshd_config is not readable"],
272 syntaxOk: raw.syntax ? raw.syntax.code === 0 : null,
273 syntaxMessage: cleanText(raw.syntax?.output, 400),
274 managed: { state: managed ? (managed.policies.size ? "present" : "absent") : "unreadable", consistent: managed ? managed.consistent : false, path: windowsPolicy.CONFIG_PATH },
275 restartRequired: true,
276 sshName: name,
277 policy,
278 effective,
279 warnings
280 },
281 keys: { files: keyFiles.map(({ raw: _raw, ...file }) => file), home: raw.profilePath || null },
282 system: platform || null,
283 collectedAt: new Date().toISOString(),
284 _keyFiles: keyFiles,
285 _config: { text: config, sha256: raw.ssh.configSha256, managed },
286 _addresses: addresses,
287 _groups: groupNames
288 };
289 }
291 _mapKeyFile(file, ownerSid, profilePath) {
292 const rawBytes = file.data ? Buffer.from(file.data, "base64") : Buffer.alloc(0);
293 let reason = null;
294 if (!file.path || file.type === "unresolved") reason = "unsupported-token";
295 else if (file.type === "needs-elevation") reason = "needs-elevation";
296 else if (file.reparse || file.type === "reparse") reason = "symlink";
297 else if (file.type === "other") reason = "not-regular";
298 else if (file.type === "unreadable") reason = "unreadable";
299 else if (file.tooLarge) reason = "too-large";
300 else if (file.type === "absent-dir" && (file.shared || !profilePath)) reason = "profile-missing";
301 else if (!["file", "absent", "absent-dir"].includes(file.type)) reason = "unknown";
302 const allowed = new Set(["S-1-5-18", ADMINS_SID, ...(file.shared ? [] : [ownerSid])]);
303 const aclWarnings = [];
304 for (const entry of file.acl || []) {
305 if (entry.type === "Allow" && !allowed.has(entry.sid) && /Write|Modify|FullControl|ChangePermissions|TakeOwnership/.test(entry.rights)) {
306 aclWarnings.push(`${file.path}: ${entry.sid} has ${entry.rights}; Windows OpenSSH ignores key files writable by other principals.`);
307 }
308 }
309 return {
310 path: file.path,
311 spec: cleanText(file.spec, 200),
312 type: file.type,
313 shared: file.shared === true,
314 owner: file.owner || null,
315 acl: (file.acl || []).map((entry) => ({ sid: entry.sid, rights: cleanText(entry.rights, 120), type: entry.type, inherited: entry.inherited === true })),
316 meta: file.owner ? `owner ${file.owner}` : "",
317 dirMeta: "",
318 sha256: file.sha256 || "absent",
319 manageable: reason === null,
320 reason,
321 aclWarnings,
322 entries: file.type === "file" ? keys.parseAuthorizedKeys(rawBytes) : [],
323 raw: rawBytes
324 };
325 }
327 _assertMutable(account) {
328 if (account.protection === "builtin") throw fail("USERS_PROTECTED_USER", "Built-in Windows accounts (Administrator, Guest, DefaultAccount, WDAGUtilityAccount) are protected.");
329 if (account.protection === "session") throw fail("USERS_PROTECTED_USER", "The account used by the active SSH session is protected against changes that could lock you out.");
330 if (account.source && !/^(Local|MicrosoftAccount)$/i.test(account.source)) throw fail("USERS_DOMAIN_READ_ONLY", "Domain and cloud identities are shown read-only; CH-J does not manage them.");
331 }
333 _requireElevated(privilege) {
334 if (privilege.elevated) return;
335 throw fail("USERS_WINDOWS_ELEVATION_REQUIRED", privilege.adminMember
336 ? "The SSH session belongs to Administrators, but its token is not elevated (UAC filtered). CH-J does not bypass UAC."
337 : "The SSH account is not a local administrator; administrative operations are not available.");
338 }
340 async manage(sessionId, payload = {}, platform) {
341 const privilege = await this.privileges(sessionId);
342 this._requireElevated(privilege);
343 const requested = String(payload.action || "");
344 let result;
345 if (requested === "create") {
346 const name = validateWindowsName(payload.username);
347 const groups = [...new Set((Array.isArray(payload.groups) ? payload.groups : []).map((sid) => validateSid(sid, GROUP_SID, "USERS_GROUP_INVALID")))];
348 result = await this._run(sessionId, {
349 op: "create", name, fullName: validateText(payload.fullName ?? payload.comment, 256, "USERS_COMMENT_INVALID"),
350 description: validateText(payload.description, 48, "USERS_COMMENT_INVALID"), admin: payload.admin === true, groups,
351 ...(payload.password ? { password: validatePassword(payload.password) } : {})
352 }, "users.windows.create", 120000);
353 } else {
354 const sid = validateSid(payload.sid);
355 const inventory = await this.inventory(sessionId, platform);
356 const account = inventory.users.find((user) => user.sid === sid);
357 if (!account) throw fail("USERS_USER_NOT_FOUND", "The local account does not exist.");
358 this._assertMutable(account);
359 if (requested === "delete") {
360 result = await this._run(sessionId, { op: "delete", sid, removeProfile: payload.removeProfile === true || payload.removeHome === true }, "users.windows.delete", 120000);
361 } else {
362 const action = ACTIONS[requested];
363 if (!action) throw fail("USERS_NOT_SUPPORTED_ON_PLATFORM", "This operation exists only on Linux (shell, home directory, primary group, password lock).");
364 const args = { op: "account", action, sid };
365 if (action === "password") args.password = validatePassword(payload.password);
366 if (action === "fullName") args.value = validateText(payload.fullName ?? payload.comment, 256, "USERS_COMMENT_INVALID");
367 if (action === "description") args.value = validateText(payload.description, 48, "USERS_COMMENT_INVALID");
368 if (action === "group-add" || action === "group-remove") args.group = validateSid(payload.group, GROUP_SID, "USERS_GROUP_INVALID");
369 result = await this._run(sessionId, args, `users.windows.${action}`, 120000);
370 }
371 }
372 this.logger?.warn("Remote Windows account action completed.", { sessionId, action: requested, result: result.result });
373 const warnings = [];
374 if (requested === "delete" && payload.sid) {
375 try { await this._cleanupPolicy(sessionId, payload, warnings); } catch (error) { warnings.push(`CH-J SSH rules of the deleted account remain: ${error.message}`); }
376 }
377 const inventory = await this.inventory(sessionId, platform);
378 return { action: requested, username: payload.username || "", sid: result.sid || payload.sid || null, result: warnings.length && result.result === "ok" ? "partial" : result.result, steps: result.steps || [], warnings, inventory, users: inventory.users };
379 }
381 async _cleanupPolicy(sessionId, payload, warnings) {
382 const name = windowsPolicy.sshName(payload.username);
383 if (!name) return;
384 const session = await this.privileges(sessionId);
385 const context = await this.detail(sessionId, { sid: session.sid });
386 if (!context._config.managed?.policies.has(name)) return;
387 warnings.push(...(await this._applyPolicy(sessionId, context, name, null, payload.probeCredentials || {})).warnings);
388 }
390 async keys(sessionId, payload = {}, platform) {
391 const action = String(payload.action || "");
392 if (!["add", "remove"].includes(action)) throw fail("USERS_ACTION_INVALID", "Unsupported key action.");
393 const target = await this._resolveTarget(sessionId, payload);
394 const detail = await this.detail(sessionId, target, platform);
395 const session = this.sessionManager.sessionAuthInfo(sessionId);
396 if (detail.account.protection === "builtin") throw fail("USERS_PROTECTED_USER", "Keys of built-in accounts are shown read-only.");
397 if (!detail.self) this._requireElevated(detail.privilege);
398 let file; let content;
399 if (action === "add") {
400 const key = keys.normalizePublicKey(payload.publicKey, payload.comment);
401 for (const candidate of detail._keyFiles) {
402 if (candidate.entries.some((entry) => entry.blob === key.blob)) throw fail("USERS_KEY_DUPLICATE", `This public key is already authorized in ${candidate.path}.`);
403 }
404 file = detail._keyFiles[0];
405 if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", `The effective authorized_keys file cannot be managed safely (${file?.reason || "not found"}).`);
406 content = keys.appendKey(file.raw, key);
407 } else {
408 file = detail._keyFiles.find((candidate) => candidate.path === payload.path);
409 if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", "This key file cannot be changed safely.");
410 const sessionUsesFile = detail.self || (file.shared && detail.privilege.adminMember);
411 if (sessionUsesFile && session.authMethod === "privateKey") {
412 if (session.publicKeyFingerprint && session.publicKeyFingerprint === payload.fingerprint) throw fail("USERS_KEY_IN_USE", "This key is used by the active CH-J session and is not removed.");
413 if (file.entries.filter((entry) => entry.valid).length <= 1) throw fail("USERS_KEY_LAST", "The last key of the file used by the active session is not removed.");
414 }
415 content = keys.removeKey(file.raw, payload.line, String(payload.fingerprint || ""));
416 }
417 await this._run(sessionId, {
418 op: "keys-write", sid: detail.sid, path: file.path, expected: file.type === "file" ? keys.sha256Hex(file.raw) : "absent",
419 data: content.toString("base64"), addr4: detail._addresses.ipv4, addr6: detail._addresses.ipv6
420 }, `users.windows.keys.${action}`, 120000);
421 this.logger?.warn("Remote Windows authorized keys changed.", { sessionId, action, path: file.path, shared: file.shared });
422 const refreshed = await this.detail(sessionId, { sid: detail.sid }, platform);
423 return { action, username: detail.username, sid: detail.sid, path: file.path, shared: file.shared, detail: refreshed };
424 }
426 async sshPolicy(sessionId, payload = {}, platform) {
427 const target = await this._resolveTarget(sessionId, payload);
428 const policy = policyTools.normalizePolicy(payload.policy || {}, "windows-account");
429 const detail = await this.detail(sessionId, target, platform);
430 this._requireElevated(detail.privilege);
431 if (detail.account.protection === "builtin") throw fail("USERS_PROTECTED_USER", "SSH access of built-in accounts is not managed by CH-J.");
432 if (!detail.ssh.sshName) throw fail("USERS_SSH_NAME_UNSUPPORTED", "The account name cannot be used safely in sshd_config.");
433 const result = await this._applyPolicy(sessionId, detail, detail.ssh.sshName, policy, payload.probeCredentials || {}, detail.self);
434 let refreshed = null;
435 try { refreshed = await this.detail(sessionId, { sid: detail.sid }, platform); }
436 catch (error) { if (error.code !== "SESSION_NOT_CONNECTED") throw error; result.sessionLost = true; }
437 return { ...result, detail: refreshed };
438 }
440 // Stage (sshd -t, sshd -T -f candidate) -> commit through a SYSTEM scheduled task that arms the rollback
441 // before restarting sshd -> confirm from a NEW SSH connection -> otherwise the task restores the backup.
442 async _applyPolicy(sessionId, detail, name, policy, probeCredentials, self = false) {
443 const privilege = detail.privilege;
444 if (!detail.ssh.available) throw fail("USERS_SSHD_MISSING", "OpenSSH Server (sshd.exe) is not installed.");
445 if (detail.ssh.syntaxOk === false) throw fail("USERS_SSH_CONFIG_ALREADY_INVALID", `The current sshd_config is invalid: ${detail.ssh.syntaxMessage}`);
446 const { text, sha256, managed } = detail._config;
447 if (!managed) throw fail("USERS_SSH_LAYOUT_UNSUPPORTED", "sshd_config is not readable.");
448 const policies = new Map(managed.policies);
449 if (!policy || policyTools.isDefaultPolicy(policy)) policies.delete(name); else policies.set(name, policy);
450 let content;
451 try { content = windowsPolicy.renderConfig(text, policies); }
452 catch (error) { throw fail(error.code || "USERS_SSH_LAYOUT_UNSUPPORTED", error.message); }
453 if (content === text) return { changed: false, applied: false, policy: policy || { ...policyTools.POLICY_DEFAULTS }, warnings: [] };
454 const clientAddress = privilege.clientAddress;
455 if (!net.isIP(clientAddress)) throw fail("USERS_SSH_CLIENT_UNKNOWN", "The client address of the SSH session is unknown, so lockout protection cannot be verified.");
456 // Every Windows change restarts sshd, so a fresh login is always required to keep it.
457 const requirements = this.sessionManager.probeLoginRequirements(sessionId, probeCredentials);
458 if (!requirements.ready) throw fail("USERS_SSH_PROBE_CREDENTIALS_REQUIRED", `Windows OpenSSH must be restarted; a test login (${requirements.missing}) is required to keep the change.`);
459 const target = this.sessionManager.connectionTarget(sessionId);
460 const session = this.sessionManager.sessionAuthInfo(sessionId);
461 const sessionName = windowsPolicy.sshName(accountName(privilege.username)) || accountName(privilege.username).toLowerCase();
462 const method = session.authMethod === "privateKey" ? "publickey" : "password";
463 const addresses = detail._addresses;
464 const tuples = [];
465 if (policy) tuples.push({ user: name, addr: addresses.ipv4 }, { user: name, addr: addresses.ipv6 });
466 tuples.push({ user: sessionName, addr: clientAddress });
467 const staged = await this._run(sessionId, { op: "ssh-stage", expected: sha256, data: Buffer.from(content, "utf8").toString("base64"), tuples }, "users.windows.ssh-stage", 120000);
468 if (staged.syntaxCode !== 0) throw fail("USERS_SSH_CONFIG_INVALID", cleanText(staged.syntaxOutput, 400) || "sshd -t rejected the new configuration.");
469 const parse = (entry) => {
470 const parsed = policyTools.parseEffective(entry?.code === 0 ? entry.text : "");
471 if (!entry || entry.code !== 0) parsed.errors.push(cleanText(entry?.text || "missing", 200));
472 return parsed;
473 };
474 const problems = [];
475 if (policy) for (const [family, label] of [["ipv4", "IPv4"], ["ipv6", "IPv6"]]) {
476 const effective = parse(staged.after[`${name}|${addresses[family]}`]);
477 if (effective.errors.length) problems.push(`${label}: sshd -T did not return a usable result`);
478 else problems.push(...policyTools.verifyPolicyEffect(policy, name, effective, label, { keyboardInteractive: false, ipv4Pattern: policyTools.WINDOWS_IPV4_PATTERN, ipv6Pattern: policyTools.WINDOWS_IPV6_PATTERN }));
479 // With UseDNS the host part is also matched against reverse-DNS names, where *.*.*.* is not IPv4-specific.
480 if ((policy.ipv4 === "deny" || policy.ipv6 === "deny") && effective.values.usedns === "yes") problems.push(`${label}: UseDNS yes makes address-family rules ambiguous`);
481 }
482 if (problems.length) throw fail("USERS_SSH_POLICY_NOT_EFFECTIVE", `Existing sshd rules take precedence (${problems.join("; ")}). Nothing was changed.`);
483 const sessionKey = `${sessionName}|${clientAddress}`;
484 const before = parse(staged.before[sessionKey]);
485 const after = parse(staged.after[sessionKey]);
486 const context = { username: sessionName, groups: detail.self ? detail._groups : privilege.groups, address: clientAddress, method, keyboardInteractive: false };
487 const evaluationAfter = policyTools.evaluateAccess(after, context);
488 if (after.errors.length || evaluationAfter.methodAllowed === false || evaluationAfter.allowed === false) {
489 throw fail("USERS_SSH_LOCKOUT_PREVENTED", `The active session (${method} from ${clientAddress}) would no longer be allowed to log in. Nothing was changed.`);
490 }
491 if (!self) {
492 const comparable = (effective) => JSON.stringify({ evaluation: policyTools.evaluateAccess(effective, context), settings: Object.entries(effective.values).filter(([key]) => !["denyusers", "allowusers", "denygroups", "allowgroups"].includes(key)).sort() });
493 if (comparable(before) !== comparable(after)) throw fail("USERS_SSH_LOCKOUT_PREVENTED", "The change would alter the effective SSH access of the active session account. Nothing was changed.");
494 }
495 const nonce = crypto.randomBytes(24).toString("hex");
496 const commit = await this._run(sessionId, { op: "ssh-commit", id: staged.id, nonce, expected: sha256, candidateSha256: staged.candidateSha256, timeoutSeconds: APPLY_TIMEOUT_SECONDS }, "users.windows.ssh-commit", 120000);
497 this.logger?.warn("Windows sshd change armed with rollback guard.", { sessionId, id: staged.id, state: commit.state });
498 if (commit.state !== "awaiting-confirmation") {
499 throw fail("USERS_SSH_ROLLED_BACK", `The change was not kept (${commit.state}): ${cleanText(commit.message, 300)}`);
500 }
501 const confirmInput = (confirm, waitSeconds) => bootstrapInput(REMOTE_SCRIPT, { op: "ssh-confirm", id: staged.id, nonce, confirm, waitSeconds });
502 let confirmation;
503 try {
504 const fresh = await this.sessionManager.freshExec(target, probeCredentials, { command: encodedCommand(BOOTSTRAP), input: confirmInput(true, 25), timeoutMs: 90000, acceptAnyExit: true });
505 confirmation = parseWindowsOutput(fresh.stdout, fresh.stderr, fresh.code);
506 } catch (error) {
507 const rollback = await this._awaitRollback(sessionId, target, probeCredentials, confirmInput(false, APPLY_TIMEOUT_SECONDS + 30));
508 throw fail("USERS_SSH_PROBE_FAILED", `${error.message} The rollback guard reported: ${rollback}`);
509 }
510 if (confirmation.state !== "confirmed") throw fail("USERS_SSH_ROLLED_BACK", `The change was not kept (${confirmation.state}): ${cleanText(confirmation.message, 300)}`);
511 this.logger?.warn("Windows sshd change confirmed by a new SSH login.", { sessionId, id: staged.id });
512 const warnings = [];
513 try { this.sessionManager._requireConnected(sessionId); }
514 catch { warnings.push("The original SSH session was closed by the sshd restart. Reconnect in the main window."); }
515 return { changed: true, applied: true, probed: true, restarted: true, policy: policy || { ...policyTools.POLICY_DEFAULTS }, applyId: staged.id, warnings };
516 }
518 // Waits for the server-side guard to restore the configuration, through the old session if it is still alive.
519 async _awaitRollback(sessionId, target, credentials, input) {
520 try {
521 this.sessionManager._requireConnected(sessionId);
522 const status = await this.executor.run(sessionId, { script: REMOTE_SCRIPT, args: JSON.parse(Buffer.from(input.split("\n")[1], "base64").toString("utf8")), timeoutMs: (APPLY_TIMEOUT_SECONDS + 60) * 1000 });
523 return `${status.state} (${cleanText(status.message, 200)})`;
524 } catch {}
525 try {
526 await new Promise((resolve) => setTimeout(resolve, (APPLY_TIMEOUT_SECONDS + 15) * 1000));
527 const fresh = await this.sessionManager.freshExec(target, credentials, { command: encodedCommand(BOOTSTRAP), input, timeoutMs: (APPLY_TIMEOUT_SECONDS + 60) * 1000, acceptAnyExit: true });
528 const status = parseWindowsOutput(fresh.stdout, fresh.stderr, fresh.code);
529 return `${status.state} (${cleanText(status.message, 200)})`;
530 } catch (error) {
531 return `unknown (${cleanText(error.message, 200)}); the scheduled task restores the previous sshd_config automatically after ${APPLY_TIMEOUT_SECONDS} s`;
532 }
533 }
536module.exports = { ADMINS_SID, APPLY_TIMEOUT_SECONDS, REMOTE_SCRIPT, WindowsUserAdminService, privilegeFromToken, validateSid, validateWindowsName };
SHA-2568164bcc28d2e198147e744272952dd81eee6f4cde1fdc6d2067c3cd303223fd7

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivscf4455b698f0f28e456a37b1834976a0194d8486acb7101052d59a838745cca3
Dateien