Publicerad källa
Ladda ner källa ZIP
CH-J Server Manager
Bläddra i kataloger och filer för en specifik programutgåva.
CH-J / Server Manager
377 filer
core-20261019T111616Z-160a00bfcddc
Tillagd: · Byggd:
1
"use strict";3
const crypto = require("node:crypto");4
const fs = require("node:fs");5
const net = require("node:net");6
const path = require("node:path");7
const { SessionError } = require("../sessions/sessionManager");8
const { BOOTSTRAP, bootstrapInput, encodedCommand, parseWindowsOutput } = require("../sessions/windowsPrivilegedExec");9
const keys = require("./authorizedKeys");10
const policyTools = require("./sshPolicy");11
const windowsPolicy = require("./windowsSshPolicy");13
const REMOTE_SCRIPT = fs.readFileSync(path.join(__dirname, "windows", "remoteUsers.ps1"), "utf8");14
const ADMINS_SID = "S-1-5-32-544";15
const LOCAL_SID = /^S-1-5-21-\d+-\d+-\d+-\d+$/;16
const GROUP_SID = /^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$/;17
const NAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$/;18
const SAMPLE_IPV4 = "192.0.2.10";19
const SAMPLE_IPV6 = "2001:db8::10";20
const APPLY_TIMEOUT_SECONDS = 120;21
const FINAL_STATES = new Set(["confirmed", "rolled-back", "rollback-failed", "aborted"]);22
const 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
});28
function fail(code, message) { return new SessionError(code, message); }29
function cleanText(value, limit = 300) { return String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit); }31
function 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;35
}36
function 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;40
}41
function 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;45
}46
function 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;50
}52
function 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 };55
}57
function 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
};74
}76
// The account name used in sshd_config and in sshd -T for a local user ("codex", not "HOST\\codex").77
function accountName(tokenName) { return String(tokenName || "").split("\\").pop(); }79
function decodeConfig(ssh) {80
return ssh?.configData ? Buffer.from(ssh.configData, "base64").toString("utf8") : "";81
}83
class 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 : null138
};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" : null263
},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
warnings280
},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: groupNames288
};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: rawBytes324
};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.adminMember336
? "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.ipv6420
}, `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 rollback441
// 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
}534
}536
module.exports = { ADMINS_SID, APPLY_TIMEOUT_SECONDS, REMOTE_SCRIPT, WindowsUserAdminService, privilegeFromToken, validateSid, validateWindowsName };SHA-256
8164bcc28d2e198147e744272952dd81eee6f4cde1fdc6d2067c3cd303223fd7CH-J Proprietary Software License 1.14 · Källan finns under CH-J Proprietary Software License 1.14. Dess tillgänglighet ändrar inte licensvillkoren eller ger ytterligare rättigheter.
Arkiv SHA-256
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a