Sorgente pubblicata
Scarica sorgente ZIP
CH-J Server Manager
Sfoglia directory e file per una specifica release di applicazioni.
CH-J / Server Manager
377 file
core-20261019T111616Z-160a00bfcddc
Aggiunto: · Costruito:
1
"use strict";3
const fs = require("node:fs");4
const net = require("node:net");5
const path = require("node:path");6
const { SessionError } = require("../sessions/sessionManager");7
const keys = require("./authorizedKeys");8
const policyTools = require("./sshPolicy");10
const REMOTE_SCRIPT = fs.readFileSync(path.join(__dirname, "remoteUsers.sh"), "utf8");11
const NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;12
const SHELL_PATTERN = /^\/[A-Za-z0-9._/+-]{1,127}$/;13
const HOME_PATTERN = /^\/[A-Za-z0-9._/@+-]{1,254}$/;14
const FORBIDDEN_HOMES = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"];15
const SAMPLE_IPV4 = "192.0.2.10";16
const SAMPLE_IPV6 = "2001:db8::10";17
const SSH_WATCHDOG_SECONDS = 120;18
const ACTION_ALIASES = Object.freeze({19
grantAdmin: "sudo-grant",20
revokeAdmin: "sudo-revoke",21
setPassword: "password",22
setShell: "shell",23
setComment: "comment",24
setHome: "home",25
setPrimaryGroup: "primary",26
addGroup: "group-add",27
removeGroup: "group-remove"28
});29
const ACCOUNT_ACTIONS = new Set(["lock", "unlock", "expire", "unexpire", "password", "shell", "comment", "home", "primary", "group-add", "group-remove", "sudo-grant", "sudo-revoke"]);30
// Actions that can remove the last working administrator.31
const ADMIN_REDUCING = new Set(["delete", "lock", "expire", "sudo-revoke", "shell"]);33
function fail(code, message) {34
return new SessionError(code, message);35
}37
function validateName(value, code = "USERS_NAME_INVALID") {38
const name = String(value ?? "").trim();39
if (!NAME_PATTERN.test(name)) throw fail(code, "Names must start with a lowercase letter or underscore and contain only a-z, 0-9, _ or - (max. 32).");40
return name;41
}43
function validateShell(value) {44
const shell = String(value ?? "").trim();45
if (!SHELL_PATTERN.test(shell) || shell.includes("/../") || shell.endsWith("/..")) throw fail("USERS_SHELL_INVALID", "Invalid shell path.");46
return shell;47
}49
function validateHome(value) {50
const home = String(value ?? "").trim().replace(/\/+$/, "");51
if (!HOME_PATTERN.test(home) || /\/\.\.?(\/|$)|\/\//.test(home)) throw fail("USERS_HOME_INVALID", "The home directory must be an absolute path without special characters.");52
if (FORBIDDEN_HOMES.some((root) => home === root || (root !== "/var" && home.startsWith(`${root}/`)))) throw fail("USERS_HOME_INVALID", "System directories cannot be used as a home directory.");53
return home;54
}56
function validateComment(value) {57
const comment = String(value ?? "").trim();58
if (comment.length > 100 || /[:\\\x00-\x1f\x7f]/.test(comment)) throw fail("USERS_COMMENT_INVALID", "The full name may contain up to 100 characters without ':' or control characters.");59
return comment;60
}62
function validateAccountPassword(value) {63
const password = String(value ?? "");64
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.");65
return password;66
}68
function splitSections(text) {69
const sections = {};70
const pattern = /\x1eCHJ_SECTION=([a-z0-9]+)\r?\n/g;71
let current = null; let start = 0; let match;72
while ((match = pattern.exec(text))) {73
if (current) sections[current] = text.slice(start, match.index);74
current = match[1]; start = pattern.lastIndex;75
}76
if (current) sections[current] = text.slice(start);77
return sections;78
}80
function keyValues(text) {81
const values = {};82
for (const line of String(text || "").split(/\r?\n/)) {83
const index = line.indexOf("=");84
if (index > 0 && !(line.slice(0, index) in values)) values[line.slice(0, index)] = line.slice(index + 1);85
}86
return values;87
}89
function lines(text) {90
return String(text || "").split(/\r?\n/).filter(Boolean);91
}93
function cleanText(value, limit = 300) {94
return String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit);95
}97
function parseManagedSection(text) {98
const values = keyValues(text);99
if (values.unsafe === "1") return { state: "unsafe", sha256: null, content: "", policies: new Map(), consistent: false };100
if (values.unreadable === "1") return { state: "unreadable", sha256: null, content: "", policies: new Map(), consistent: false };101
if (values.sha256 === "absent" || values.sha256 === undefined) return { state: "absent", sha256: "absent", content: "", policies: new Map(), consistent: true };102
const content = Buffer.from(values.data || "", "base64").toString("utf8");103
return { state: "present", sha256: values.sha256, content, ...policyTools.parseManagedConfig(content) };104
}106
function parsePasswd(line) {107
const [username = "", , uid = "", gid = "", comment = "", home = "", shell = ""] = String(line).split(":");108
return { username, uid: Number(uid), gid: Number(gid), comment: cleanText(comment.split(",")[0], 100), home: cleanText(home, 255), shell: cleanText(shell, 255) };109
}111
function parseShadowState(value) {112
const [state = "unknown", lastChange = "", expire = ""] = String(value || "").split(":");113
const expireDays = expire === "" ? null : Number(expire);114
return {115
password: ["set", "locked", "none", "empty"].includes(state) ? state : "unknown",116
lastChangeDays: lastChange === "" ? null : Number(lastChange),117
expireDays: Number.isFinite(expireDays) ? expireDays : null,118
expired: Number.isFinite(expireDays) ? expireDays * 86400000 <= Date.now() : false119
};120
}122
function isLoginShell(shell) {123
return Boolean(shell) && !/\/(nologin|false)$/.test(shell);124
}126
function familyAddresses(clientAddress) {127
const family = net.isIP(clientAddress);128
return {129
ipv4: family === 4 ? clientAddress : SAMPLE_IPV4,130
ipv6: family === 6 ? clientAddress : SAMPLE_IPV6,131
ipv4Sample: family !== 4,132
ipv6Sample: family !== 6133
};134
}136
function parseStepMarkers(stdout) {137
const steps = [];138
for (const match of String(stdout).matchAll(/\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n/g)) {139
steps.push({ step: match[1], status: match[2], message: cleanText(match[3], 300) });140
}141
const result = String(stdout).match(/\x1eCHJ_RESULT=([a-z]+)/)?.[1] || "unknown";142
const sudo = String(stdout).match(/\x1eCHJ_SUDO=([a-z-]+)/)?.[1] || null;143
return { steps, result, sudo };144
}146
function parseEffectiveBlocks(text, marker) {147
const blocks = new Map();148
const pattern = new RegExp(`\\x1eCHJ_${marker}=([^\\n]+)\\n([\\s\\S]*?)(?=\\x1eCHJ_|$)`, "g");149
for (const match of String(text).matchAll(pattern)) blocks.set(match[1], policyTools.parseEffective(match[2]));150
return blocks;151
}153
const ACCESS_LIST_KEYS = new Set(["denyusers", "allowusers", "denygroups", "allowgroups"]);155
function comparableAccess(effective, context) {156
const evaluation = policyTools.evaluateAccess(effective, context);157
const settings = Object.entries(effective.values).filter(([key]) => !ACCESS_LIST_KEYS.has(key)).sort(([left], [right]) => left.localeCompare(right));158
return JSON.stringify({ allowed: evaluation.allowed, methods: evaluation.methods, settings });159
}161
class UserAdminService {162
constructor(options) {163
this.sessionManager = options.sessionManager;164
this.executor = options.privilegedExecutor;165
this.windows = options.windowsService || null;166
this.detectPlatform = options.detectPlatform || null;167
this.logger = options.logger;168
}170
// Chooses the backend from system facts of the remote host (uname / Win32_OperatingSystem).171
async _platform(sessionId) {172
if (!this.detectPlatform) return { os: "linux" };173
const platform = await this.detectPlatform(this.sessionManager, sessionId);174
if (platform.os === "windows" && !this.windows) throw fail("USERS_PLATFORM_UNSUPPORTED", "Windows support is not available in this build.");175
if (platform.os === "unknown") throw fail("USERS_PLATFORM_UNSUPPORTED", `The remote operating system could not be identified: ${platform.reason || "no POSIX shell and no Windows PowerShell"}.`);176
return platform;177
}179
async _windows(sessionId) {180
const platform = await this._platform(sessionId);181
return platform.os === "windows" ? platform : null;182
}184
async privileges(sessionId, options = {}) {185
if (await this._windows(sessionId)) return this.windows.privileges(sessionId);186
return { platform: "linux", ...(await this.executor.probe(sessionId, { refresh: options.refresh === true })) };187
}189
// Lists accounts. Without privileges only public account data is read; shadow state and the190
// effective sudo rights need root and are fetched automatically when no password is required.191
async inventory(sessionId, options = {}) {192
const windows = await this._windows(sessionId);193
if (windows) return this.windows.inventory(sessionId, windows);194
const privilege = await this.executor.probe(sessionId);195
const privileged = this._wantsPrivilege(privilege, options);196
const run = await this._run(sessionId, { args: ["inventory"], privileged, sudoPassword: options.sudoPassword, purpose: "users.inventory", timeoutMs: 90000 });197
return this._parseInventory(sessionId, run.stdout, privileged, await this.executor.probe(sessionId));198
}200
async listCompat(sessionId) {201
const inventory = await this.inventory(sessionId);202
return inventory.users.filter((user) => user.uid === 0 || !user.system);203
}205
async detail(sessionId, options = {}) {206
const windows = await this._windows(sessionId);207
if (windows) return this.windows.detail(sessionId, options, windows);208
const username = validateName(options.username);209
const privilege = await this.executor.probe(sessionId);210
const privileged = this._wantsPrivilege(privilege, options);211
return this._detail(sessionId, username, { privileged, sudoPassword: options.sudoPassword });212
}214
async manage(sessionId, payload = {}) {215
const windows = await this._windows(sessionId);216
if (windows) return this.windows.manage(sessionId, payload, windows);217
const requested = String(payload.action || "");218
const action = ACTION_ALIASES[requested] || requested;219
const auth = { sudoPassword: payload.sudoPassword };220
if (action === "create") return this._create(sessionId, payload, auth);221
if (action !== "delete" && !ACCOUNT_ACTIONS.has(action)) throw fail("USERS_ACTION_INVALID", "Unsupported user-management action.");222
const username = validateName(payload.username);223
const args = this._accountArgs(action, username, payload);224
const before = await this.inventory(sessionId, { ...auth, privileged: true });225
const user = before.users.find((candidate) => candidate.username === username);226
if (!user) throw fail("USERS_USER_NOT_FOUND", "The account does not exist on the server.");227
this._assertMutable(user, action);228
if (ADMIN_REDUCING.has(action)) this._assertNotLastAdmin(before, user, action, payload);229
if (action === "group-remove" && before.groups.find((group) => group.name === args[3])?.gid === user.gid) {230
throw fail("USERS_GROUP_PRIMARY", "The primary group cannot be removed; change the primary group first.");231
}232
const frames = action === "password" ? [{ tag: "SECRET", data: validateAccountPassword(payload.password) }] : [];233
const run = await this._run(sessionId, { args, frames, privileged: true, sudoPassword: auth.sudoPassword, purpose: `users.${action}`, timeoutMs: 60000 });234
const outcome = parseStepMarkers(run.stdout);235
const warnings = [];236
if (action === "delete" && before.policies.has(username)) {237
try {238
await this._applyPolicyChange(sessionId, username, null, auth, {}, { targetExists: false });239
outcome.steps.push({ step: "ssh-policy", status: "ok", message: "CH-J SSH rules for the account were removed." });240
} catch (error) {241
warnings.push(`CH-J SSH rules for the deleted account remain: ${error.message}`);242
outcome.result = "partial";243
}244
}245
if (action === "sudo-grant" && outcome.sudo && !outcome.sudo.startsWith("full")) warnings.push("The account was added to the administrator group, but sudo -l does not report full sudo rights. sudoers was not changed.");246
if (action === "sudo-revoke" && outcome.sudo && outcome.sudo !== "none" && outcome.sudo !== "unavailable") warnings.push(`sudo rights remain (${outcome.sudo}) because they are granted by another sudoers rule. CH-J does not edit sudoers.`);247
this.logger?.warn("Remote user-management action completed.", { sessionId, action, username, result: outcome.result });248
const inventory = await this.inventory(sessionId, { ...auth, privileged: true });249
return { action: requested, username, result: outcome.result, steps: outcome.steps, warnings, sudo: outcome.sudo, inventory, users: inventory.users };250
}252
async keys(sessionId, payload = {}) {253
const windows = await this._windows(sessionId);254
if (windows) return this.windows.keys(sessionId, payload, windows);255
const action = String(payload.action || "");256
if (!["add", "remove"].includes(action)) throw fail("USERS_ACTION_INVALID", "Unsupported key action.");257
const username = validateName(payload.username);258
const session = this.sessionManager.sessionAuthInfo(sessionId);259
const privilege = await this.executor.probe(sessionId);260
const self = username === privilege.username;261
const privileged = !self || Boolean(payload.sudoPassword) || ["root", "sudo-nopasswd"].includes(privilege.mode);262
const auth = { privileged, sudoPassword: payload.sudoPassword };263
const detail = await this._detail(sessionId, username, auth);264
// root's SSH keys are managed (via root session or sudo); other system accounts stay read-only.265
if (detail.account.system) throw fail("USERS_PROTECTED_USER", "Keys of system accounts are shown read-only.");266
let file; let content;267
if (action === "add") {268
const key = keys.normalizePublicKey(payload.publicKey, payload.comment);269
for (const candidate of detail._keyFiles) {270
if (candidate.entries.some((entry) => entry.blob === key.blob)) throw fail("USERS_KEY_DUPLICATE", `This public key is already authorized in ${candidate.path}.`);271
}272
// New keys go only to the first AuthorizedKeysFile entry; CH-J never falls back silently.273
file = detail._keyFiles[0];274
if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", `The primary authorized_keys file cannot be managed safely (${file?.reason || "not found"}). See diagnostics.`);275
content = keys.appendKey(file.raw, key);276
} else {277
file = detail._keyFiles.find((candidate) => candidate.path === payload.path) || detail._keyFiles.find((candidate) => candidate.entries.some((entry) => entry.line === Number(payload.line) && entry.fingerprint === payload.fingerprint));278
if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", "This key file cannot be changed safely.");279
if (self && session.authMethod === "privateKey") {280
if (session.publicKeyFingerprint && session.publicKeyFingerprint === payload.fingerprint) {281
throw fail("USERS_KEY_IN_USE", "This key is used by the active CH-J session and is not removed.");282
}283
const remaining = detail._keyFiles.reduce((count, candidate) => count + candidate.entries.filter((entry) => entry.valid).length, 0);284
if (remaining <= 1) throw fail("USERS_KEY_LAST", "The last authorized key of the active session account is not removed.");285
}286
content = keys.removeKey(file.raw, payload.line, String(payload.fingerprint || ""));287
}288
const expected = file.type === "file" ? keys.sha256Hex(file.raw) : "absent";289
await this._run(sessionId, { args: ["keys-write", username, file.path, expected], frames: [{ tag: "DATA", data: content }], ...auth, purpose: `users.keys.${action}`, timeoutMs: 30000 });290
this.logger?.warn("Remote authorized_keys changed.", { sessionId, username, action, path: file.path });291
const refreshed = await this._detail(sessionId, username, auth);292
return { action, username, path: file.path, detail: refreshed };293
}295
async sshPolicy(sessionId, payload = {}) {296
const windows = await this._windows(sessionId);297
if (windows) return this.windows.sshPolicy(sessionId, payload, windows);298
const username = validateName(payload.username);299
const policy = policyTools.normalizePolicy(payload.policy || {}, username);300
// Password login for root is never enabled implicitly: it needs a separate explicit confirmation.301
if (username === "root" && (policy.password === "yes" || policy.permitRootLogin === "yes") && payload.confirmRootPassword !== true) {302
throw fail("USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED", "Allowing password login for root requires an explicit additional confirmation.");303
}304
const auth = { sudoPassword: payload.sudoPassword };305
const result = await this._applyPolicyChange(sessionId, username, policy, auth, payload.probeCredentials || {});306
const detail = await this._detail(sessionId, username, { privileged: true, sudoPassword: auth.sudoPassword });307
return { ...result, detail };308
}310
_wantsPrivilege(privilege, options) {311
return Boolean(options.sudoPassword) || options.privileged === true || ["root", "sudo-nopasswd"].includes(privilege.mode);312
}314
async _run(sessionId, { args, frames = [], privileged, sudoPassword, purpose, timeoutMs, interact }) {315
return this.executor.run(sessionId, { script: REMOTE_SCRIPT, args, frames, privileged, sudoPassword, purpose, timeoutMs, interact, errorPrefix: "USERS_" });316
}318
_parseInventory(sessionId, stdout, privileged, privilege) {319
const sections = splitSections(stdout);320
const meta = keyValues(sections.meta);321
const tools = new Set(lines(sections.meta).filter((line) => line.startsWith("tool=")).map((line) => line.slice(5)));322
const uidMin = Number(meta.uidMin) || 1000;323
const groups = lines(sections.group).map((line) => {324
const [name = "", , gid = "", members = ""] = line.split(":");325
return { name, gid: Number(gid), members: members.split(",").filter(Boolean) };326
}).filter((group) => NAME_PATTERN.test(group.name) || group.name);327
const groupByGid = new Map(groups.map((group) => [group.gid, group.name]));328
const shadow = new Map(lines(sections.shadow).map((line) => {329
const [name, ...rest] = line.split(":");330
return [name, parseShadowState(rest.join(":"))];331
}));332
const sudo = new Map(lines(sections.sudo).map((line) => line.split(":")));333
const adminGroups = lines(sections.admingroups);334
const managed = parseManagedSection(sections.managed);335
const sessionUser = privilege.username;336
const users = lines(sections.passwd).map(parsePasswd).filter((user) => user.username && Number.isInteger(user.uid)).map((user) => {337
const state = shadow.get(user.username) || { password: "unknown", lastChangeDays: null, expireDays: null, expired: null };338
const supplementary = groups.filter((group) => group.members.includes(user.username)).map((group) => group.name);339
const memberAdminGroups = ["sudo", "wheel", "admin"].filter((name) => supplementary.includes(name) || groupByGid.get(user.gid) === name);340
const sudoState = privileged ? (sudo.get(user.username) || (user.uid === 0 ? "full" : "unknown")) : "unknown";341
const system = user.uid !== 0 && (user.uid < uidMin || user.uid === 65534);342
const sshPolicy = managed.policies.get(user.username) || null;343
return {344
...user,345
displayName: user.comment,346
primaryGroup: groupByGid.get(user.gid) || String(user.gid),347
groups: supplementary,348
system,349
loginShell: isLoginShell(user.shell),350
...state,351
locked: state.password === "locked",352
passwordState: state.password,353
sudo: sudoState,354
adminGroups: memberAdminGroups,355
admin: user.uid === 0 || (sudoState === "unknown" ? memberAdminGroups.length > 0 : sudoState.startsWith("full")),356
sshPolicy,357
protection: user.uid === 0 ? "root" : system ? "system" : user.username === sessionUser ? "session" : null358
};359
}).sort((left, right) => Number(left.system) - Number(right.system) || left.uid - right.uid || left.username.localeCompare(right.username));360
return {361
platform: "linux",362
sessionId,363
privileged,364
privilege,365
server: { os: cleanText(meta.os, 120), osId: cleanText(meta.osId, 40), uidMin, tools: [...tools].sort(), sshLayout: meta.layout || "unknown", managedPath: policyTools.MANAGED_PATH },366
shells: lines(sections.shells).filter((shell) => SHELL_PATTERN.test(shell)),367
groups: groups.map((group) => ({ name: group.name, gid: group.gid, members: group.members })),368
adminGroups,369
managedPolicy: { state: managed.state, consistent: managed.consistent, users: [...managed.policies.keys()] },370
policies: managed.policies,371
users,372
collectedAt: new Date().toISOString()373
};374
}376
async _detail(sessionId, username, { privileged, sudoPassword }) {377
const privilege = await this.executor.probe(sessionId);378
const session = this.sessionManager.sessionAuthInfo(sessionId);379
const addresses = familyAddresses(privilege.clientAddress);380
const run = await this._run(sessionId, { args: ["detail", username, addresses.ipv4, addresses.ipv6], privileged, sudoPassword, purpose: "users.detail", timeoutMs: 60000 });381
const sections = splitSections(run.stdout);382
const accountValues = keyValues(sections.account);383
const account = parsePasswd(accountValues.passwd || "");384
if (account.username !== username) throw fail("USERS_PROTOCOL_ERROR", "The server returned an unexpected account.");385
const groups = String(accountValues.groups || "").split(",").filter(Boolean);386
const state = accountValues.shadow ? parseShadowState(accountValues.shadow) : { password: "unknown", lastChangeDays: null, expireDays: null, expired: null };387
const uidMin = Number(accountValues.uidMin) || 1000;388
const system = account.uid !== 0 && (account.uid < uidMin || account.uid === 65534);389
const sshValues = keyValues(sections.ssh);390
const managed = parseManagedSection(sections.managed);391
const policy = managed.policies.get(username) || { ...policyTools.POLICY_DEFAULTS };392
const effective = {};393
const warnings = [];394
for (const [family, label] of [["ipv4", "effective4"], ["ipv6", "effective6"]]) {395
if (!sections[label]) continue;396
const parsed = policyTools.parseEffective(sections[label]);397
const evaluation = policyTools.evaluateAccess(parsed, { username, groups, address: addresses[family] });398
effective[family] = {399
address: addresses[family],400
sample: addresses[`${family}Sample`],401
passwordAuthentication: parsed.values.passwordauthentication || "unknown",402
kbdInteractiveAuthentication: parsed.values.kbdinteractiveauthentication || parsed.values.challengeresponseauthentication || "unknown",403
pubkeyAuthentication: parsed.values.pubkeyauthentication || "unknown",404
authenticationMethods: parsed.values.authenticationmethods || "any",405
permitRootLogin: parsed.values.permitrootlogin || "unknown",406
allowed: evaluation.allowed,407
reasons: evaluation.reasons,408
methods: evaluation.methods,409
errors: parsed.errors.slice(0, 5)410
};411
effective.listener ||= policyTools.listenerFamilies(parsed);412
effective.authorizedKeysCommand ||= parsed.values.authorizedkeyscommand && parsed.values.authorizedkeyscommand !== "none" ? cleanText(parsed.values.authorizedkeyscommand, 200) : null;413
}414
if (effective.listener && !effective.listener.ipv6) warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy.");415
if (effective.listener && !effective.listener.ipv4) warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy.");416
if (effective.authorizedKeysCommand) warnings.push("AuthorizedKeysCommand is configured; keys may also come from an external source that CH-J does not manage.");417
if (managed.state === "present" && !managed.consistent) warnings.push(`${policyTools.MANAGED_PATH} was edited manually; CH-J will not overwrite it.`);418
const keyFiles = this._parseKeyFiles(sections.keys, account.home);419
return {420
platform: "linux",421
sessionId,422
username,423
privileged: run.privileged !== false && privileged,424
self: username === privilege.username,425
session: { authMethod: session.authMethod, username: privilege.username, clientAddress: privilege.clientAddress },426
account: {427
...account,428
primaryGroup: groups[0] || String(account.gid),429
groups: groups.slice(1),430
system,431
loginShell: isLoginShell(account.shell),432
processes: accountValues.processes === undefined ? null : Number(accountValues.processes),433
homeMeta: cleanText(accountValues.homeMeta, 120),434
...state,435
sudo: accountValues.sudo || (privileged ? "unknown" : "unknown"),436
protection: account.uid === 0 ? "root" : system ? "system" : username === privilege.username ? "session" : null437
},438
ssh: {439
available: sshValues.sshd !== undefined && sshValues.sshd !== "missing",440
version: cleanText(sshValues.version, 160),441
layout: sshValues.layout || "unknown",442
layoutSupported: sshValues.layout === "ok",443
service: cleanText(sshValues.service, 120),444
syntaxOk: sshValues.syntax === undefined ? null : sshValues.syntax === "0",445
syntaxMessage: cleanText(sshValues.syntaxMessage, 400),446
managed: { state: managed.state, consistent: managed.consistent, path: policyTools.MANAGED_PATH },447
policy,448
effective,449
warnings450
},451
keys: { files: keyFiles.map(({ raw, ...file }) => file), home: account.home },452
collectedAt: new Date().toISOString(),453
// Internal fields for key editing; stripped by _publicDetail before IPC.454
_keyFiles: keyFiles,455
_managed: managed,456
_addresses: addresses,457
_groups: groups458
};459
}461
_parseKeyFiles(text, home) {462
const files = [];463
for (const block of String(text || "").split("\x1eCHJ_KEYFILE=").slice(1)) {464
const [pathLine, ...rest] = block.split(/\r?\n/);465
const values = keyValues(rest.join("\n"));466
const filePath = cleanText(pathLine, 300);467
const raw = values.data ? Buffer.from(values.data, "base64") : Buffer.alloc(0);468
const insideHome = Boolean(home) && home !== "/" && filePath.startsWith(`${home}/`) && !/\/\.\.?(\/|$)/.test(filePath);469
const type = values.type || "unknown";470
let reason = null;471
if (!insideHome) reason = "outside-home";472
else if (type === "symlink") reason = "symlink";473
else if (type === "other") reason = "not-regular";474
else if (type === "unreadable") reason = "unreadable";475
else if (type === "unsupported") reason = "unsupported-token";476
else if (values.tooLarge === "1") reason = "too-large";477
else if (!["file", "absent", "absent-dir"].includes(type)) reason = "unknown";478
files.push({479
path: filePath,480
spec: cleanText(values.spec, 200),481
type,482
meta: cleanText(values.meta, 120),483
dirMeta: cleanText(values.dirMeta, 120),484
manageable: reason === null,485
reason,486
entries: type === "file" ? keys.parseAuthorizedKeys(raw) : [],487
raw488
});489
}490
return files;491
}493
async _create(sessionId, payload, auth) {494
const username = validateName(payload.username);495
const inventory = await this.inventory(sessionId, { ...auth, privileged: true });496
if (inventory.users.some((user) => user.username === username)) throw fail("USERS_USER_EXISTS", "The account already exists.");497
const fallbackShell = inventory.shells.includes("/bin/bash") ? "/bin/bash" : "/bin/sh";498
const shell = validateShell(payload.shell || fallbackShell);499
const home = payload.home ? validateHome(payload.home) : "";500
const primaryGroup = payload.primaryGroup ? validateName(payload.primaryGroup, "USERS_GROUP_INVALID") : "";501
const groupList = [...new Set((Array.isArray(payload.groups) ? payload.groups : []).map((group) => validateName(group, "USERS_GROUP_INVALID")))];502
if (groupList.length > 32) throw fail("USERS_GROUP_INVALID", "Too many groups.");503
const comment = validateComment(payload.comment || "");504
const withPassword = payload.password !== undefined && payload.password !== null && payload.password !== "";505
const frames = withPassword ? [{ tag: "SECRET", data: validateAccountPassword(payload.password) }] : [];506
const args = ["create", username, shell, home, payload.createHome === false ? "0" : "1", primaryGroup, groupList.join(","), comment, payload.admin === true ? "1" : "0", withPassword ? "1" : "0"];507
const run = await this._run(sessionId, { args, frames, privileged: true, sudoPassword: auth.sudoPassword, purpose: "users.create", timeoutMs: 60000 });508
const outcome = parseStepMarkers(run.stdout);509
this.logger?.warn("Remote account created.", { sessionId, username, result: outcome.result });510
const refreshed = await this.inventory(sessionId, { ...auth, privileged: true });511
const created = refreshed.users.find((user) => user.username === username);512
const warnings = [];513
if (!created) warnings.push("The server did not list the new account after creation.");514
if (payload.admin === true && created && !String(created.sudo).startsWith("full")) warnings.push("sudo -l does not report full sudo rights for the new account.");515
return { action: "create", username, result: created ? outcome.result : "partial", steps: outcome.steps, warnings, inventory: refreshed, users: refreshed.users };516
}518
_accountArgs(action, username, payload) {519
if (action === "delete") return ["delete", username, payload.removeHome === true ? "1" : "0"];520
if (action === "shell") return ["account", "shell", username, validateShell(payload.shell)];521
if (action === "comment") return ["account", "comment", username, validateComment(payload.comment)];522
if (action === "home") return ["account", "home", username, validateHome(payload.home), payload.moveHome === true ? "1" : "0"];523
if (["primary", "group-add", "group-remove"].includes(action)) return ["account", action, username, validateName(payload.group, "USERS_GROUP_INVALID")];524
return ["account", action, username];525
}527
_assertMutable(user, action) {528
if (user.protection === "root") throw fail("USERS_PROTECTED_USER", "The root account is protected and cannot be changed here.");529
if (user.protection === "system") throw fail("USERS_PROTECTED_USER", "System accounts are protected and cannot be changed here.");530
if (user.protection === "session") {531
throw fail("USERS_PROTECTED_USER", action === "password"532
? "Change the password of the active session account with passwd in the terminal; CH-J would otherwise invalidate the stored login."533
: "The account used by the active SSH session is protected against changes that could lock you out.");534
}535
}537
_assertNotLastAdmin(inventory, user, action, payload) {538
if (inventory.privilege.mode === "root") return;539
if (action === "shell" && isLoginShell(String(payload.shell || ""))) return;540
const functional = inventory.users.filter((candidate) => candidate.uid !== 0 && String(candidate.sudo).startsWith("full") && !candidate.expired && candidate.loginShell);541
if (functional.some((candidate) => candidate.username === user.username) && functional.every((candidate) => candidate.username === user.username)) {542
throw fail("USERS_LAST_ADMIN", "This is the last working administrator account; the change was refused.");543
}544
}546
// Stages, verifies, reloads and confirms one change of the CH-J managed sshd drop-in.547
// With targetExists=false (cleanup after deletion) the session account supplies the SSH context.548
async _applyPolicyChange(sessionId, username, policy, auth, probeCredentials, { targetExists = true } = {}) {549
const privilege = await this.executor.probe(sessionId);550
const session = this.sessionManager.sessionAuthInfo(sessionId);551
const detail = await this._detail(sessionId, targetExists ? username : privilege.username, { privileged: true, sudoPassword: auth.sudoPassword });552
if (targetExists && detail.account.system) throw fail("USERS_PROTECTED_USER", "SSH access of system accounts is not managed by CH-J.");553
if (!detail.ssh.available) throw fail("USERS_SSHD_MISSING", "OpenSSH server was not found.");554
if (!detail.ssh.layoutSupported) throw fail("USERS_SSH_LAYOUT_UNSUPPORTED", "sshd_config does not include sshd_config.d before its first Match block; CH-J does not edit the main configuration.");555
if (detail.ssh.syntaxOk === false) throw fail("USERS_SSH_CONFIG_ALREADY_INVALID", `The current SSH configuration is invalid: ${detail.ssh.syntaxMessage}`);556
const managed = detail._managed;557
if (managed.state === "unsafe" || managed.state === "unreadable") throw fail("USERS_SSH_MANAGED_FILE_UNSAFE", "The CH-J managed SSH file is not a readable regular file.");558
if (!managed.consistent) throw fail("USERS_SSH_MANAGED_FILE_MODIFIED", `${policyTools.MANAGED_PATH} was edited manually; CH-J will not overwrite it.`);559
const policies = new Map(managed.policies);560
if (!policy || policyTools.isDefaultPolicy(policy)) policies.delete(username); else policies.set(username, policy);561
const content = policyTools.generateManagedConfig(policies);562
if (content === managed.content) return { changed: false, applied: false, policy: policy || { ...policyTools.POLICY_DEFAULTS } };563
const self = username === privilege.username;564
const method = session.authMethod === "privateKey" ? "publickey" : "password";565
const clientAddress = privilege.clientAddress;566
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.");567
if (self) {568
const requirements = this.sessionManager.probeLoginRequirements(sessionId, probeCredentials);569
if (!requirements.ready) {570
throw fail("USERS_SSH_PROBE_CREDENTIALS_REQUIRED", `Changing SSH access of the active session account requires a test login (${requirements.missing}).`);571
}572
}573
const addresses = detail._addresses;574
const sessionTuple = `${privilege.username}|${clientAddress}`;575
const tuples = targetExists ? [`${username}|${addresses.ipv4}`, `${username}|${addresses.ipv6}`, sessionTuple] : [sessionTuple];576
const sessionGroups = self ? detail._groups : privilege.groups;577
const sessionContext = { username: privilege.username, groups: sessionGroups, address: clientAddress, method };578
const run = await this._run(sessionId, {579
args: ["ssh-apply", managed.sha256 || "absent", String(SSH_WATCHDOG_SECONDS), ...new Set(tuples)],580
frames: [{ tag: "DATA", data: content }],581
privileged: true,582
sudoPassword: auth.sudoPassword,583
purpose: "users.ssh-policy",584
timeoutMs: (SSH_WATCHDOG_SECONDS + 60) * 1000,585
interact: async (io) => {586
const staged = await io.waitFor(/\x1eCHJ_STAGED\r?\n/, 60000);587
const before = parseEffectiveBlocks(staged.before, "BEFORE");588
const after = parseEffectiveBlocks(staged.before, "AFTER");589
const problems = [];590
if (policy && targetExists) {591
for (const [family, label] of [["ipv4", "IPv4"], ["ipv6", "IPv6"]]) {592
const effective = after.get(`${username}|${addresses[family]}`);593
if (!effective || effective.errors.length) problems.push(`${label}: sshd -T did not return a usable result`);594
else problems.push(...policyTools.verifyPolicyEffect(policy, username, effective, label));595
}596
}597
const sessionBefore = before.get(sessionTuple);598
const sessionAfter = after.get(sessionTuple);599
let lockout = null;600
if (!sessionAfter || sessionAfter.errors.length) lockout = "sshd -T could not evaluate the active session account.";601
else if (self) {602
const evaluation = policyTools.evaluateAccess(sessionAfter, { username, groups: sessionGroups, address: clientAddress, method });603
if (evaluation.methodAllowed !== true) lockout = `The active session (${method} from ${clientAddress}) would no longer be allowed to log in.`;604
} else if (!sessionBefore || comparableAccess(sessionBefore, sessionContext) !== comparableAccess(sessionAfter, sessionContext)) {605
// DenyUsers lines of other accounts appear in every sshd -T dump, so compare the evaluated result.606
lockout = "The change would alter the effective SSH access of the active session account.";607
}608
if (problems.length || lockout) {609
io.send("ROLLBACK");610
await io.waitFor(/\x1eCHJ_ROLLED_BACK=[^\n]*\n/, 30000);611
return { rolledBack: true, problems, lockout };612
}613
io.send("COMMIT");614
const reloaded = await io.waitFor(/\x1eCHJ_RELOADED=([^\n]*)\n/, 60000);615
if (self) {616
try { await this.sessionManager.probeLogin(sessionId, probeCredentials); }617
catch (error) {618
io.send("ROLLBACK");619
await io.waitFor(/\x1eCHJ_ROLLED_BACK=[^\n]*\n/, 60000);620
return { rolledBack: true, problems: [], lockout: null, probeError: error.message };621
}622
}623
io.send("CONFIRM");624
const confirmed = await io.waitFor(/\x1eCHJ_CONFIRMED=([^\n]*)\n/, 30000);625
return { rolledBack: false, reload: cleanText(reloaded.match[1], 200), backup: cleanText(confirmed.match[1], 300), probed: self };626
}627
});628
const outcome = run.interaction;629
if (outcome.rolledBack) {630
this.logger?.warn("SSH policy change rolled back.", { sessionId, username, reason: outcome.lockout ? "lockout" : outcome.probeError ? "probe" : "not-effective" });631
if (outcome.lockout) throw fail("USERS_SSH_LOCKOUT_PREVENTED", `${outcome.lockout} The previous configuration was restored.`);632
if (outcome.probeError) throw fail("USERS_SSH_PROBE_FAILED", `${outcome.probeError} The previous SSH configuration was restored and reloaded.`);633
throw fail("USERS_SSH_POLICY_NOT_EFFECTIVE", `Existing sshd rules take precedence (${outcome.problems.join("; ")}). The previous configuration was restored.`);634
}635
this.logger?.warn("SSH policy applied and confirmed.", { sessionId, username, probed: outcome.probed });636
return { changed: true, applied: true, policy: policy || { ...policyTools.POLICY_DEFAULTS }, reload: outcome.reload, backup: outcome.backup, probed: outcome.probed };637
}638
}640
// Internal fields (raw key bytes, parsed config, Maps) start with "_" and never cross IPC.641
function publicDetail(detail) {642
if (!detail || typeof detail !== "object") return detail;643
return Object.fromEntries(Object.entries(detail).filter(([key]) => !key.startsWith("_")));644
}646
function publicInventory(inventory) {647
if (!inventory || typeof inventory !== "object") return inventory;648
const { policies, ...rest } = inventory;649
return rest;650
}652
// IPC-safe facade: strips internal buffers and Map instances from every result.653
function createUserAdminApi(service) {654
const clean = (result) => {655
if (!result || typeof result !== "object") return result;656
const copy = { ...result };657
if (copy.inventory) copy.inventory = publicInventory(copy.inventory);658
if (copy.detail) copy.detail = publicDetail(copy.detail);659
return copy;660
};661
return {662
privileges: (sessionId, options) => service.privileges(sessionId, options),663
inventory: async (sessionId, options) => publicInventory(await service.inventory(sessionId, options)),664
list: (sessionId) => service.listCompat(sessionId),665
detail: async (sessionId, options) => publicDetail(await service.detail(sessionId, options)),666
manage: async (sessionId, payload) => clean(await service.manage(sessionId, payload)),667
keys: async (sessionId, payload) => clean(await service.keys(sessionId, payload)),668
sshPolicy: async (sessionId, payload) => clean(await service.sshPolicy(sessionId, payload))669
};670
}672
module.exports = {673
REMOTE_SCRIPT,674
UserAdminService,675
createUserAdminApi,676
familyAddresses,677
parseStepMarkers,678
splitSections,679
validateComment,680
validateHome,681
validateName,682
validateShell683
};SHA-256
51f082b5d514fdbafbdcfd8bca30231718be908f649f3098fe93e0d39881a19fCH-J Proprietary Software License 1.14 · La sorgente è fornita sotto CH-J Proprietary Software License 1.14. La sua disponibilità non modifica i termini di licenza o concede diritti aggiuntivi.
Archivio SHA-256
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a