"use strict"; const fs = require("node:fs"); const net = require("node:net"); const path = require("node:path"); const { SessionError } = require("../sessions/sessionManager"); const keys = require("./authorizedKeys"); const policyTools = require("./sshPolicy"); const REMOTE_SCRIPT = fs.readFileSync(path.join(__dirname, "remoteUsers.sh"), "utf8"); const NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/; const SHELL_PATTERN = /^\/[A-Za-z0-9._/+-]{1,127}$/; const HOME_PATTERN = /^\/[A-Za-z0-9._/@+-]{1,254}$/; const FORBIDDEN_HOMES = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"]; const SAMPLE_IPV4 = "192.0.2.10"; const SAMPLE_IPV6 = "2001:db8::10"; const SSH_WATCHDOG_SECONDS = 120; const ACTION_ALIASES = Object.freeze({ grantAdmin: "sudo-grant", revokeAdmin: "sudo-revoke", setPassword: "password", setShell: "shell", setComment: "comment", setHome: "home", setPrimaryGroup: "primary", addGroup: "group-add", removeGroup: "group-remove" }); const ACCOUNT_ACTIONS = new Set(["lock", "unlock", "expire", "unexpire", "password", "shell", "comment", "home", "primary", "group-add", "group-remove", "sudo-grant", "sudo-revoke"]); // Actions that can remove the last working administrator. const ADMIN_REDUCING = new Set(["delete", "lock", "expire", "sudo-revoke", "shell"]); function fail(code, message) { return new SessionError(code, message); } function validateName(value, code = "USERS_NAME_INVALID") { const name = String(value ?? "").trim(); 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)."); return name; } function validateShell(value) { const shell = String(value ?? "").trim(); if (!SHELL_PATTERN.test(shell) || shell.includes("/../") || shell.endsWith("/..")) throw fail("USERS_SHELL_INVALID", "Invalid shell path."); return shell; } function validateHome(value) { const home = String(value ?? "").trim().replace(/\/+$/, ""); if (!HOME_PATTERN.test(home) || /\/\.\.?(\/|$)|\/\//.test(home)) throw fail("USERS_HOME_INVALID", "The home directory must be an absolute path without special characters."); 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."); return home; } function validateComment(value) { const comment = String(value ?? "").trim(); 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."); return comment; } function validateAccountPassword(value) { const password = String(value ?? ""); 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."); return password; } function splitSections(text) { const sections = {}; const pattern = /\x1eCHJ_SECTION=([a-z0-9]+)\r?\n/g; let current = null; let start = 0; let match; while ((match = pattern.exec(text))) { if (current) sections[current] = text.slice(start, match.index); current = match[1]; start = pattern.lastIndex; } if (current) sections[current] = text.slice(start); return sections; } function keyValues(text) { const values = {}; for (const line of String(text || "").split(/\r?\n/)) { const index = line.indexOf("="); if (index > 0 && !(line.slice(0, index) in values)) values[line.slice(0, index)] = line.slice(index + 1); } return values; } function lines(text) { return String(text || "").split(/\r?\n/).filter(Boolean); } function cleanText(value, limit = 300) { return String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit); } function parseManagedSection(text) { const values = keyValues(text); if (values.unsafe === "1") return { state: "unsafe", sha256: null, content: "", policies: new Map(), consistent: false }; if (values.unreadable === "1") return { state: "unreadable", sha256: null, content: "", policies: new Map(), consistent: false }; if (values.sha256 === "absent" || values.sha256 === undefined) return { state: "absent", sha256: "absent", content: "", policies: new Map(), consistent: true }; const content = Buffer.from(values.data || "", "base64").toString("utf8"); return { state: "present", sha256: values.sha256, content, ...policyTools.parseManagedConfig(content) }; } function parsePasswd(line) { const [username = "", , uid = "", gid = "", comment = "", home = "", shell = ""] = String(line).split(":"); return { username, uid: Number(uid), gid: Number(gid), comment: cleanText(comment.split(",")[0], 100), home: cleanText(home, 255), shell: cleanText(shell, 255) }; } function parseShadowState(value) { const [state = "unknown", lastChange = "", expire = ""] = String(value || "").split(":"); const expireDays = expire === "" ? null : Number(expire); return { password: ["set", "locked", "none", "empty"].includes(state) ? state : "unknown", lastChangeDays: lastChange === "" ? null : Number(lastChange), expireDays: Number.isFinite(expireDays) ? expireDays : null, expired: Number.isFinite(expireDays) ? expireDays * 86400000 <= Date.now() : false }; } function isLoginShell(shell) { return Boolean(shell) && !/\/(nologin|false)$/.test(shell); } function familyAddresses(clientAddress) { const family = net.isIP(clientAddress); return { ipv4: family === 4 ? clientAddress : SAMPLE_IPV4, ipv6: family === 6 ? clientAddress : SAMPLE_IPV6, ipv4Sample: family !== 4, ipv6Sample: family !== 6 }; } function parseStepMarkers(stdout) { const steps = []; for (const match of String(stdout).matchAll(/\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n/g)) { steps.push({ step: match[1], status: match[2], message: cleanText(match[3], 300) }); } const result = String(stdout).match(/\x1eCHJ_RESULT=([a-z]+)/)?.[1] || "unknown"; const sudo = String(stdout).match(/\x1eCHJ_SUDO=([a-z-]+)/)?.[1] || null; return { steps, result, sudo }; } function parseEffectiveBlocks(text, marker) { const blocks = new Map(); const pattern = new RegExp(`\\x1eCHJ_${marker}=([^\\n]+)\\n([\\s\\S]*?)(?=\\x1eCHJ_|$)`, "g"); for (const match of String(text).matchAll(pattern)) blocks.set(match[1], policyTools.parseEffective(match[2])); return blocks; } const ACCESS_LIST_KEYS = new Set(["denyusers", "allowusers", "denygroups", "allowgroups"]); function comparableAccess(effective, context) { const evaluation = policyTools.evaluateAccess(effective, context); const settings = Object.entries(effective.values).filter(([key]) => !ACCESS_LIST_KEYS.has(key)).sort(([left], [right]) => left.localeCompare(right)); return JSON.stringify({ allowed: evaluation.allowed, methods: evaluation.methods, settings }); } class UserAdminService { constructor(options) { this.sessionManager = options.sessionManager; this.executor = options.privilegedExecutor; this.windows = options.windowsService || null; this.detectPlatform = options.detectPlatform || null; this.logger = options.logger; } // Chooses the backend from system facts of the remote host (uname / Win32_OperatingSystem). async _platform(sessionId) { if (!this.detectPlatform) return { os: "linux" }; const platform = await this.detectPlatform(this.sessionManager, sessionId); if (platform.os === "windows" && !this.windows) throw fail("USERS_PLATFORM_UNSUPPORTED", "Windows support is not available in this build."); 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"}.`); return platform; } async _windows(sessionId) { const platform = await this._platform(sessionId); return platform.os === "windows" ? platform : null; } async privileges(sessionId, options = {}) { if (await this._windows(sessionId)) return this.windows.privileges(sessionId); return { platform: "linux", ...(await this.executor.probe(sessionId, { refresh: options.refresh === true })) }; } // Lists accounts. Without privileges only public account data is read; shadow state and the // effective sudo rights need root and are fetched automatically when no password is required. async inventory(sessionId, options = {}) { const windows = await this._windows(sessionId); if (windows) return this.windows.inventory(sessionId, windows); const privilege = await this.executor.probe(sessionId); const privileged = this._wantsPrivilege(privilege, options); const run = await this._run(sessionId, { args: ["inventory"], privileged, sudoPassword: options.sudoPassword, purpose: "users.inventory", timeoutMs: 90000 }); return this._parseInventory(sessionId, run.stdout, privileged, await this.executor.probe(sessionId)); } async listCompat(sessionId) { const inventory = await this.inventory(sessionId); return inventory.users.filter((user) => user.uid === 0 || !user.system); } async detail(sessionId, options = {}) { const windows = await this._windows(sessionId); if (windows) return this.windows.detail(sessionId, options, windows); const username = validateName(options.username); const privilege = await this.executor.probe(sessionId); const privileged = this._wantsPrivilege(privilege, options); return this._detail(sessionId, username, { privileged, sudoPassword: options.sudoPassword }); } async manage(sessionId, payload = {}) { const windows = await this._windows(sessionId); if (windows) return this.windows.manage(sessionId, payload, windows); const requested = String(payload.action || ""); const action = ACTION_ALIASES[requested] || requested; const auth = { sudoPassword: payload.sudoPassword }; if (action === "create") return this._create(sessionId, payload, auth); if (action !== "delete" && !ACCOUNT_ACTIONS.has(action)) throw fail("USERS_ACTION_INVALID", "Unsupported user-management action."); const username = validateName(payload.username); const args = this._accountArgs(action, username, payload); const before = await this.inventory(sessionId, { ...auth, privileged: true }); const user = before.users.find((candidate) => candidate.username === username); if (!user) throw fail("USERS_USER_NOT_FOUND", "The account does not exist on the server."); this._assertMutable(user, action); if (ADMIN_REDUCING.has(action)) this._assertNotLastAdmin(before, user, action, payload); if (action === "group-remove" && before.groups.find((group) => group.name === args[3])?.gid === user.gid) { throw fail("USERS_GROUP_PRIMARY", "The primary group cannot be removed; change the primary group first."); } const frames = action === "password" ? [{ tag: "SECRET", data: validateAccountPassword(payload.password) }] : []; const run = await this._run(sessionId, { args, frames, privileged: true, sudoPassword: auth.sudoPassword, purpose: `users.${action}`, timeoutMs: 60000 }); const outcome = parseStepMarkers(run.stdout); const warnings = []; if (action === "delete" && before.policies.has(username)) { try { await this._applyPolicyChange(sessionId, username, null, auth, {}, { targetExists: false }); outcome.steps.push({ step: "ssh-policy", status: "ok", message: "CH-J SSH rules for the account were removed." }); } catch (error) { warnings.push(`CH-J SSH rules for the deleted account remain: ${error.message}`); outcome.result = "partial"; } } 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."); 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.`); this.logger?.warn("Remote user-management action completed.", { sessionId, action, username, result: outcome.result }); const inventory = await this.inventory(sessionId, { ...auth, privileged: true }); return { action: requested, username, result: outcome.result, steps: outcome.steps, warnings, sudo: outcome.sudo, inventory, users: inventory.users }; } async keys(sessionId, payload = {}) { const windows = await this._windows(sessionId); if (windows) return this.windows.keys(sessionId, payload, windows); const action = String(payload.action || ""); if (!["add", "remove"].includes(action)) throw fail("USERS_ACTION_INVALID", "Unsupported key action."); const username = validateName(payload.username); const session = this.sessionManager.sessionAuthInfo(sessionId); const privilege = await this.executor.probe(sessionId); const self = username === privilege.username; const privileged = !self || Boolean(payload.sudoPassword) || ["root", "sudo-nopasswd"].includes(privilege.mode); const auth = { privileged, sudoPassword: payload.sudoPassword }; const detail = await this._detail(sessionId, username, auth); // root's SSH keys are managed (via root session or sudo); other system accounts stay read-only. if (detail.account.system) throw fail("USERS_PROTECTED_USER", "Keys of system accounts are shown read-only."); let file; let content; if (action === "add") { const key = keys.normalizePublicKey(payload.publicKey, payload.comment); for (const candidate of detail._keyFiles) { if (candidate.entries.some((entry) => entry.blob === key.blob)) throw fail("USERS_KEY_DUPLICATE", `This public key is already authorized in ${candidate.path}.`); } // New keys go only to the first AuthorizedKeysFile entry; CH-J never falls back silently. file = detail._keyFiles[0]; if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", `The primary authorized_keys file cannot be managed safely (${file?.reason || "not found"}). See diagnostics.`); content = keys.appendKey(file.raw, key); } else { 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)); if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", "This key file cannot be changed safely."); if (self && session.authMethod === "privateKey") { 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."); } const remaining = detail._keyFiles.reduce((count, candidate) => count + candidate.entries.filter((entry) => entry.valid).length, 0); if (remaining <= 1) throw fail("USERS_KEY_LAST", "The last authorized key of the active session account is not removed."); } content = keys.removeKey(file.raw, payload.line, String(payload.fingerprint || "")); } const expected = file.type === "file" ? keys.sha256Hex(file.raw) : "absent"; await this._run(sessionId, { args: ["keys-write", username, file.path, expected], frames: [{ tag: "DATA", data: content }], ...auth, purpose: `users.keys.${action}`, timeoutMs: 30000 }); this.logger?.warn("Remote authorized_keys changed.", { sessionId, username, action, path: file.path }); const refreshed = await this._detail(sessionId, username, auth); return { action, username, path: file.path, detail: refreshed }; } async sshPolicy(sessionId, payload = {}) { const windows = await this._windows(sessionId); if (windows) return this.windows.sshPolicy(sessionId, payload, windows); const username = validateName(payload.username); const policy = policyTools.normalizePolicy(payload.policy || {}, username); // Password login for root is never enabled implicitly: it needs a separate explicit confirmation. if (username === "root" && (policy.password === "yes" || policy.permitRootLogin === "yes") && payload.confirmRootPassword !== true) { throw fail("USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED", "Allowing password login for root requires an explicit additional confirmation."); } const auth = { sudoPassword: payload.sudoPassword }; const result = await this._applyPolicyChange(sessionId, username, policy, auth, payload.probeCredentials || {}); const detail = await this._detail(sessionId, username, { privileged: true, sudoPassword: auth.sudoPassword }); return { ...result, detail }; } _wantsPrivilege(privilege, options) { return Boolean(options.sudoPassword) || options.privileged === true || ["root", "sudo-nopasswd"].includes(privilege.mode); } async _run(sessionId, { args, frames = [], privileged, sudoPassword, purpose, timeoutMs, interact }) { return this.executor.run(sessionId, { script: REMOTE_SCRIPT, args, frames, privileged, sudoPassword, purpose, timeoutMs, interact, errorPrefix: "USERS_" }); } _parseInventory(sessionId, stdout, privileged, privilege) { const sections = splitSections(stdout); const meta = keyValues(sections.meta); const tools = new Set(lines(sections.meta).filter((line) => line.startsWith("tool=")).map((line) => line.slice(5))); const uidMin = Number(meta.uidMin) || 1000; const groups = lines(sections.group).map((line) => { const [name = "", , gid = "", members = ""] = line.split(":"); return { name, gid: Number(gid), members: members.split(",").filter(Boolean) }; }).filter((group) => NAME_PATTERN.test(group.name) || group.name); const groupByGid = new Map(groups.map((group) => [group.gid, group.name])); const shadow = new Map(lines(sections.shadow).map((line) => { const [name, ...rest] = line.split(":"); return [name, parseShadowState(rest.join(":"))]; })); const sudo = new Map(lines(sections.sudo).map((line) => line.split(":"))); const adminGroups = lines(sections.admingroups); const managed = parseManagedSection(sections.managed); const sessionUser = privilege.username; const users = lines(sections.passwd).map(parsePasswd).filter((user) => user.username && Number.isInteger(user.uid)).map((user) => { const state = shadow.get(user.username) || { password: "unknown", lastChangeDays: null, expireDays: null, expired: null }; const supplementary = groups.filter((group) => group.members.includes(user.username)).map((group) => group.name); const memberAdminGroups = ["sudo", "wheel", "admin"].filter((name) => supplementary.includes(name) || groupByGid.get(user.gid) === name); const sudoState = privileged ? (sudo.get(user.username) || (user.uid === 0 ? "full" : "unknown")) : "unknown"; const system = user.uid !== 0 && (user.uid < uidMin || user.uid === 65534); const sshPolicy = managed.policies.get(user.username) || null; return { ...user, displayName: user.comment, primaryGroup: groupByGid.get(user.gid) || String(user.gid), groups: supplementary, system, loginShell: isLoginShell(user.shell), ...state, locked: state.password === "locked", passwordState: state.password, sudo: sudoState, adminGroups: memberAdminGroups, admin: user.uid === 0 || (sudoState === "unknown" ? memberAdminGroups.length > 0 : sudoState.startsWith("full")), sshPolicy, protection: user.uid === 0 ? "root" : system ? "system" : user.username === sessionUser ? "session" : null }; }).sort((left, right) => Number(left.system) - Number(right.system) || left.uid - right.uid || left.username.localeCompare(right.username)); return { platform: "linux", sessionId, privileged, privilege, server: { os: cleanText(meta.os, 120), osId: cleanText(meta.osId, 40), uidMin, tools: [...tools].sort(), sshLayout: meta.layout || "unknown", managedPath: policyTools.MANAGED_PATH }, shells: lines(sections.shells).filter((shell) => SHELL_PATTERN.test(shell)), groups: groups.map((group) => ({ name: group.name, gid: group.gid, members: group.members })), adminGroups, managedPolicy: { state: managed.state, consistent: managed.consistent, users: [...managed.policies.keys()] }, policies: managed.policies, users, collectedAt: new Date().toISOString() }; } async _detail(sessionId, username, { privileged, sudoPassword }) { const privilege = await this.executor.probe(sessionId); const session = this.sessionManager.sessionAuthInfo(sessionId); const addresses = familyAddresses(privilege.clientAddress); const run = await this._run(sessionId, { args: ["detail", username, addresses.ipv4, addresses.ipv6], privileged, sudoPassword, purpose: "users.detail", timeoutMs: 60000 }); const sections = splitSections(run.stdout); const accountValues = keyValues(sections.account); const account = parsePasswd(accountValues.passwd || ""); if (account.username !== username) throw fail("USERS_PROTOCOL_ERROR", "The server returned an unexpected account."); const groups = String(accountValues.groups || "").split(",").filter(Boolean); const state = accountValues.shadow ? parseShadowState(accountValues.shadow) : { password: "unknown", lastChangeDays: null, expireDays: null, expired: null }; const uidMin = Number(accountValues.uidMin) || 1000; const system = account.uid !== 0 && (account.uid < uidMin || account.uid === 65534); const sshValues = keyValues(sections.ssh); const managed = parseManagedSection(sections.managed); const policy = managed.policies.get(username) || { ...policyTools.POLICY_DEFAULTS }; const effective = {}; const warnings = []; for (const [family, label] of [["ipv4", "effective4"], ["ipv6", "effective6"]]) { if (!sections[label]) continue; const parsed = policyTools.parseEffective(sections[label]); const evaluation = policyTools.evaluateAccess(parsed, { username, groups, address: addresses[family] }); effective[family] = { address: addresses[family], sample: addresses[`${family}Sample`], passwordAuthentication: parsed.values.passwordauthentication || "unknown", kbdInteractiveAuthentication: parsed.values.kbdinteractiveauthentication || parsed.values.challengeresponseauthentication || "unknown", pubkeyAuthentication: parsed.values.pubkeyauthentication || "unknown", authenticationMethods: parsed.values.authenticationmethods || "any", permitRootLogin: parsed.values.permitrootlogin || "unknown", allowed: evaluation.allowed, reasons: evaluation.reasons, methods: evaluation.methods, errors: parsed.errors.slice(0, 5) }; effective.listener ||= policyTools.listenerFamilies(parsed); effective.authorizedKeysCommand ||= parsed.values.authorizedkeyscommand && parsed.values.authorizedkeyscommand !== "none" ? cleanText(parsed.values.authorizedkeyscommand, 200) : null; } if (effective.listener && !effective.listener.ipv6) warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy."); if (effective.listener && !effective.listener.ipv4) warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy."); if (effective.authorizedKeysCommand) warnings.push("AuthorizedKeysCommand is configured; keys may also come from an external source that CH-J does not manage."); if (managed.state === "present" && !managed.consistent) warnings.push(`${policyTools.MANAGED_PATH} was edited manually; CH-J will not overwrite it.`); const keyFiles = this._parseKeyFiles(sections.keys, account.home); return { platform: "linux", sessionId, username, privileged: run.privileged !== false && privileged, self: username === privilege.username, session: { authMethod: session.authMethod, username: privilege.username, clientAddress: privilege.clientAddress }, account: { ...account, primaryGroup: groups[0] || String(account.gid), groups: groups.slice(1), system, loginShell: isLoginShell(account.shell), processes: accountValues.processes === undefined ? null : Number(accountValues.processes), homeMeta: cleanText(accountValues.homeMeta, 120), ...state, sudo: accountValues.sudo || (privileged ? "unknown" : "unknown"), protection: account.uid === 0 ? "root" : system ? "system" : username === privilege.username ? "session" : null }, ssh: { available: sshValues.sshd !== undefined && sshValues.sshd !== "missing", version: cleanText(sshValues.version, 160), layout: sshValues.layout || "unknown", layoutSupported: sshValues.layout === "ok", service: cleanText(sshValues.service, 120), syntaxOk: sshValues.syntax === undefined ? null : sshValues.syntax === "0", syntaxMessage: cleanText(sshValues.syntaxMessage, 400), managed: { state: managed.state, consistent: managed.consistent, path: policyTools.MANAGED_PATH }, policy, effective, warnings }, keys: { files: keyFiles.map(({ raw, ...file }) => file), home: account.home }, collectedAt: new Date().toISOString(), // Internal fields for key editing; stripped by _publicDetail before IPC. _keyFiles: keyFiles, _managed: managed, _addresses: addresses, _groups: groups }; } _parseKeyFiles(text, home) { const files = []; for (const block of String(text || "").split("\x1eCHJ_KEYFILE=").slice(1)) { const [pathLine, ...rest] = block.split(/\r?\n/); const values = keyValues(rest.join("\n")); const filePath = cleanText(pathLine, 300); const raw = values.data ? Buffer.from(values.data, "base64") : Buffer.alloc(0); const insideHome = Boolean(home) && home !== "/" && filePath.startsWith(`${home}/`) && !/\/\.\.?(\/|$)/.test(filePath); const type = values.type || "unknown"; let reason = null; if (!insideHome) reason = "outside-home"; else if (type === "symlink") reason = "symlink"; else if (type === "other") reason = "not-regular"; else if (type === "unreadable") reason = "unreadable"; else if (type === "unsupported") reason = "unsupported-token"; else if (values.tooLarge === "1") reason = "too-large"; else if (!["file", "absent", "absent-dir"].includes(type)) reason = "unknown"; files.push({ path: filePath, spec: cleanText(values.spec, 200), type, meta: cleanText(values.meta, 120), dirMeta: cleanText(values.dirMeta, 120), manageable: reason === null, reason, entries: type === "file" ? keys.parseAuthorizedKeys(raw) : [], raw }); } return files; } async _create(sessionId, payload, auth) { const username = validateName(payload.username); const inventory = await this.inventory(sessionId, { ...auth, privileged: true }); if (inventory.users.some((user) => user.username === username)) throw fail("USERS_USER_EXISTS", "The account already exists."); const fallbackShell = inventory.shells.includes("/bin/bash") ? "/bin/bash" : "/bin/sh"; const shell = validateShell(payload.shell || fallbackShell); const home = payload.home ? validateHome(payload.home) : ""; const primaryGroup = payload.primaryGroup ? validateName(payload.primaryGroup, "USERS_GROUP_INVALID") : ""; const groupList = [...new Set((Array.isArray(payload.groups) ? payload.groups : []).map((group) => validateName(group, "USERS_GROUP_INVALID")))]; if (groupList.length > 32) throw fail("USERS_GROUP_INVALID", "Too many groups."); const comment = validateComment(payload.comment || ""); const withPassword = payload.password !== undefined && payload.password !== null && payload.password !== ""; const frames = withPassword ? [{ tag: "SECRET", data: validateAccountPassword(payload.password) }] : []; const args = ["create", username, shell, home, payload.createHome === false ? "0" : "1", primaryGroup, groupList.join(","), comment, payload.admin === true ? "1" : "0", withPassword ? "1" : "0"]; const run = await this._run(sessionId, { args, frames, privileged: true, sudoPassword: auth.sudoPassword, purpose: "users.create", timeoutMs: 60000 }); const outcome = parseStepMarkers(run.stdout); this.logger?.warn("Remote account created.", { sessionId, username, result: outcome.result }); const refreshed = await this.inventory(sessionId, { ...auth, privileged: true }); const created = refreshed.users.find((user) => user.username === username); const warnings = []; if (!created) warnings.push("The server did not list the new account after creation."); if (payload.admin === true && created && !String(created.sudo).startsWith("full")) warnings.push("sudo -l does not report full sudo rights for the new account."); return { action: "create", username, result: created ? outcome.result : "partial", steps: outcome.steps, warnings, inventory: refreshed, users: refreshed.users }; } _accountArgs(action, username, payload) { if (action === "delete") return ["delete", username, payload.removeHome === true ? "1" : "0"]; if (action === "shell") return ["account", "shell", username, validateShell(payload.shell)]; if (action === "comment") return ["account", "comment", username, validateComment(payload.comment)]; if (action === "home") return ["account", "home", username, validateHome(payload.home), payload.moveHome === true ? "1" : "0"]; if (["primary", "group-add", "group-remove"].includes(action)) return ["account", action, username, validateName(payload.group, "USERS_GROUP_INVALID")]; return ["account", action, username]; } _assertMutable(user, action) { if (user.protection === "root") throw fail("USERS_PROTECTED_USER", "The root account is protected and cannot be changed here."); if (user.protection === "system") throw fail("USERS_PROTECTED_USER", "System accounts are protected and cannot be changed here."); if (user.protection === "session") { throw fail("USERS_PROTECTED_USER", action === "password" ? "Change the password of the active session account with passwd in the terminal; CH-J would otherwise invalidate the stored login." : "The account used by the active SSH session is protected against changes that could lock you out."); } } _assertNotLastAdmin(inventory, user, action, payload) { if (inventory.privilege.mode === "root") return; if (action === "shell" && isLoginShell(String(payload.shell || ""))) return; const functional = inventory.users.filter((candidate) => candidate.uid !== 0 && String(candidate.sudo).startsWith("full") && !candidate.expired && candidate.loginShell); if (functional.some((candidate) => candidate.username === user.username) && functional.every((candidate) => candidate.username === user.username)) { throw fail("USERS_LAST_ADMIN", "This is the last working administrator account; the change was refused."); } } // Stages, verifies, reloads and confirms one change of the CH-J managed sshd drop-in. // With targetExists=false (cleanup after deletion) the session account supplies the SSH context. async _applyPolicyChange(sessionId, username, policy, auth, probeCredentials, { targetExists = true } = {}) { const privilege = await this.executor.probe(sessionId); const session = this.sessionManager.sessionAuthInfo(sessionId); const detail = await this._detail(sessionId, targetExists ? username : privilege.username, { privileged: true, sudoPassword: auth.sudoPassword }); if (targetExists && detail.account.system) throw fail("USERS_PROTECTED_USER", "SSH access of system accounts is not managed by CH-J."); if (!detail.ssh.available) throw fail("USERS_SSHD_MISSING", "OpenSSH server was not found."); 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."); if (detail.ssh.syntaxOk === false) throw fail("USERS_SSH_CONFIG_ALREADY_INVALID", `The current SSH configuration is invalid: ${detail.ssh.syntaxMessage}`); const managed = detail._managed; 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."); if (!managed.consistent) throw fail("USERS_SSH_MANAGED_FILE_MODIFIED", `${policyTools.MANAGED_PATH} was edited manually; CH-J will not overwrite it.`); const policies = new Map(managed.policies); if (!policy || policyTools.isDefaultPolicy(policy)) policies.delete(username); else policies.set(username, policy); const content = policyTools.generateManagedConfig(policies); if (content === managed.content) return { changed: false, applied: false, policy: policy || { ...policyTools.POLICY_DEFAULTS } }; const self = username === privilege.username; const method = session.authMethod === "privateKey" ? "publickey" : "password"; const clientAddress = privilege.clientAddress; 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."); if (self) { const requirements = this.sessionManager.probeLoginRequirements(sessionId, probeCredentials); if (!requirements.ready) { throw fail("USERS_SSH_PROBE_CREDENTIALS_REQUIRED", `Changing SSH access of the active session account requires a test login (${requirements.missing}).`); } } const addresses = detail._addresses; const sessionTuple = `${privilege.username}|${clientAddress}`; const tuples = targetExists ? [`${username}|${addresses.ipv4}`, `${username}|${addresses.ipv6}`, sessionTuple] : [sessionTuple]; const sessionGroups = self ? detail._groups : privilege.groups; const sessionContext = { username: privilege.username, groups: sessionGroups, address: clientAddress, method }; const run = await this._run(sessionId, { args: ["ssh-apply", managed.sha256 || "absent", String(SSH_WATCHDOG_SECONDS), ...new Set(tuples)], frames: [{ tag: "DATA", data: content }], privileged: true, sudoPassword: auth.sudoPassword, purpose: "users.ssh-policy", timeoutMs: (SSH_WATCHDOG_SECONDS + 60) * 1000, interact: async (io) => { const staged = await io.waitFor(/\x1eCHJ_STAGED\r?\n/, 60000); const before = parseEffectiveBlocks(staged.before, "BEFORE"); const after = parseEffectiveBlocks(staged.before, "AFTER"); const problems = []; if (policy && targetExists) { for (const [family, label] of [["ipv4", "IPv4"], ["ipv6", "IPv6"]]) { const effective = after.get(`${username}|${addresses[family]}`); if (!effective || effective.errors.length) problems.push(`${label}: sshd -T did not return a usable result`); else problems.push(...policyTools.verifyPolicyEffect(policy, username, effective, label)); } } const sessionBefore = before.get(sessionTuple); const sessionAfter = after.get(sessionTuple); let lockout = null; if (!sessionAfter || sessionAfter.errors.length) lockout = "sshd -T could not evaluate the active session account."; else if (self) { const evaluation = policyTools.evaluateAccess(sessionAfter, { username, groups: sessionGroups, address: clientAddress, method }); if (evaluation.methodAllowed !== true) lockout = `The active session (${method} from ${clientAddress}) would no longer be allowed to log in.`; } else if (!sessionBefore || comparableAccess(sessionBefore, sessionContext) !== comparableAccess(sessionAfter, sessionContext)) { // DenyUsers lines of other accounts appear in every sshd -T dump, so compare the evaluated result. lockout = "The change would alter the effective SSH access of the active session account."; } if (problems.length || lockout) { io.send("ROLLBACK"); await io.waitFor(/\x1eCHJ_ROLLED_BACK=[^\n]*\n/, 30000); return { rolledBack: true, problems, lockout }; } io.send("COMMIT"); const reloaded = await io.waitFor(/\x1eCHJ_RELOADED=([^\n]*)\n/, 60000); if (self) { try { await this.sessionManager.probeLogin(sessionId, probeCredentials); } catch (error) { io.send("ROLLBACK"); await io.waitFor(/\x1eCHJ_ROLLED_BACK=[^\n]*\n/, 60000); return { rolledBack: true, problems: [], lockout: null, probeError: error.message }; } } io.send("CONFIRM"); const confirmed = await io.waitFor(/\x1eCHJ_CONFIRMED=([^\n]*)\n/, 30000); return { rolledBack: false, reload: cleanText(reloaded.match[1], 200), backup: cleanText(confirmed.match[1], 300), probed: self }; } }); const outcome = run.interaction; if (outcome.rolledBack) { this.logger?.warn("SSH policy change rolled back.", { sessionId, username, reason: outcome.lockout ? "lockout" : outcome.probeError ? "probe" : "not-effective" }); if (outcome.lockout) throw fail("USERS_SSH_LOCKOUT_PREVENTED", `${outcome.lockout} The previous configuration was restored.`); if (outcome.probeError) throw fail("USERS_SSH_PROBE_FAILED", `${outcome.probeError} The previous SSH configuration was restored and reloaded.`); throw fail("USERS_SSH_POLICY_NOT_EFFECTIVE", `Existing sshd rules take precedence (${outcome.problems.join("; ")}). The previous configuration was restored.`); } this.logger?.warn("SSH policy applied and confirmed.", { sessionId, username, probed: outcome.probed }); return { changed: true, applied: true, policy: policy || { ...policyTools.POLICY_DEFAULTS }, reload: outcome.reload, backup: outcome.backup, probed: outcome.probed }; } } // Internal fields (raw key bytes, parsed config, Maps) start with "_" and never cross IPC. function publicDetail(detail) { if (!detail || typeof detail !== "object") return detail; return Object.fromEntries(Object.entries(detail).filter(([key]) => !key.startsWith("_"))); } function publicInventory(inventory) { if (!inventory || typeof inventory !== "object") return inventory; const { policies, ...rest } = inventory; return rest; } // IPC-safe facade: strips internal buffers and Map instances from every result. function createUserAdminApi(service) { const clean = (result) => { if (!result || typeof result !== "object") return result; const copy = { ...result }; if (copy.inventory) copy.inventory = publicInventory(copy.inventory); if (copy.detail) copy.detail = publicDetail(copy.detail); return copy; }; return { privileges: (sessionId, options) => service.privileges(sessionId, options), inventory: async (sessionId, options) => publicInventory(await service.inventory(sessionId, options)), list: (sessionId) => service.listCompat(sessionId), detail: async (sessionId, options) => publicDetail(await service.detail(sessionId, options)), manage: async (sessionId, payload) => clean(await service.manage(sessionId, payload)), keys: async (sessionId, payload) => clean(await service.keys(sessionId, payload)), sshPolicy: async (sessionId, payload) => clean(await service.sshPolicy(sessionId, payload)) }; } module.exports = { REMOTE_SCRIPT, UserAdminService, createUserAdminApi, familyAddresses, parseStepMarkers, splitSections, validateComment, validateHome, validateName, validateShell };