CH-J Server Managerupravljanje poslužiteljima preko SSH
Meni
Objavljen izvor

CH-J Server Manager

Pregledaj direktorije i datoteke za određeni programski izdanje.

Preuzmi ZIP izvora
CH-J / Server Manager 377 datoteke
core-20261019T111616Z-160a00bfcddc Dodano: · Izgrađeno:
userAdminService.js Dodano: Datum nije zabilježen 39,3 KB · 683 linije
Oznaka Preuzmi datoteku
1"use strict";
3const fs = require("node:fs");
4const net = require("node:net");
5const path = require("node:path");
6const { SessionError } = require("../sessions/sessionManager");
7const keys = require("./authorizedKeys");
8const policyTools = require("./sshPolicy");
10const REMOTE_SCRIPT = fs.readFileSync(path.join(__dirname, "remoteUsers.sh"), "utf8");
11const NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
12const SHELL_PATTERN = /^\/[A-Za-z0-9._/+-]{1,127}$/;
13const HOME_PATTERN = /^\/[A-Za-z0-9._/@+-]{1,254}$/;
14const FORBIDDEN_HOMES = ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"];
15const SAMPLE_IPV4 = "192.0.2.10";
16const SAMPLE_IPV6 = "2001:db8::10";
17const SSH_WATCHDOG_SECONDS = 120;
18const 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});
29const 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.
31const ADMIN_REDUCING = new Set(["delete", "lock", "expire", "sudo-revoke", "shell"]);
33function fail(code, message) {
34 return new SessionError(code, message);
37function 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;
43function 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;
49function 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;
56function 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;
62function 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;
68function 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;
80function 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;
89function lines(text) {
90 return String(text || "").split(/\r?\n/).filter(Boolean);
93function cleanText(value, limit = 300) {
94 return String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit);
97function 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) };
106function 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) };
111function 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() : false
119 };
122function isLoginShell(shell) {
123 return Boolean(shell) && !/\/(nologin|false)$/.test(shell);
126function 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 !== 6
133 };
136function 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 };
146function 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;
153const ACCESS_LIST_KEYS = new Set(["denyusers", "allowusers", "denygroups", "allowgroups"]);
155function 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 });
161class 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 the
190 // 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" : null
358 };
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" : null
437 },
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 warnings
450 },
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: groups
458 };
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 raw
488 });
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 }
640// Internal fields (raw key bytes, parsed config, Maps) start with "_" and never cross IPC.
641function publicDetail(detail) {
642 if (!detail || typeof detail !== "object") return detail;
643 return Object.fromEntries(Object.entries(detail).filter(([key]) => !key.startsWith("_")));
646function publicInventory(inventory) {
647 if (!inventory || typeof inventory !== "object") return inventory;
648 const { policies, ...rest } = inventory;
649 return rest;
652// IPC-safe facade: strips internal buffers and Map instances from every result.
653function 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 };
672module.exports = {
673 REMOTE_SCRIPT,
674 UserAdminService,
675 createUserAdminApi,
676 familyAddresses,
677 parseStepMarkers,
678 splitSections,
679 validateComment,
680 validateHome,
681 validateName,
682 validateShell
683};
SHA-25651f082b5d514fdbafbdcfd8bca30231718be908f649f3098fe93e0d39881a19f

CH-J Proprietary Software License 1.14 · Izvor je osiguran pod CH-J Proprietary Software License 1.14. Njena dostupnost ne mijenja uvjete licenciranja niti daje dodatna prava.

Arhiv SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Datoteke