CH-J Server Managerpengurusan pelayan melalui SSH
Menu
Sumber yang diterbitkan

CH-J Server Manager

Layari direktori dan fail untuk keluaran aplikasi tertentu.

Muat turun ZIP sumber
CH-J / Server Manager 377 fail
core-20261019T111616Z-160a00bfcddc Ditambah: · Dibina:
usersIntegration.test.js Ditambah: Tarikh tidak direkodkan 31.7 KB · 390 baris
Kod Muat turun fail
1"use strict";
3// Destructive end-to-end tests of chj.users against a disposable Docker container.
4// Run explicitly: CHJ_USERS_INTEGRATION=1 [CHJ_USERS_IMAGE=chj-users-it-debian] node --test test/usersIntegration.test.js
5// The image is built from test/fixtures/usersIntegration. Never point this test at a real server.
6const test = require("node:test");
7const assert = require("node:assert/strict");
8const fs = require("node:fs");
9const os = require("node:os");
10const path = require("node:path");
11const { execFileSync, spawnSync } = require("node:child_process");
12const { SessionManager } = require("../src/main/sessions/sessionManager");
13const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec");
14const { UserAdminService, REMOTE_SCRIPT, createUserAdminApi } = require("../src/main/users/userAdminService");
15const { parseAuthorizedKeys } = require("../src/main/users/authorizedKeys");
16const { detectPlatform } = require("../src/main/sessions/platformDetect");
18const enabled = process.env.CHJ_USERS_INTEGRATION === "1";
19const image = process.env.CHJ_USERS_IMAGE || "chj-users-it-debian";
20const skip = enabled ? false : "Set CHJ_USERS_INTEGRATION=1 to run destructive tests in a disposable Docker container.";
21const container = `chj-users-it-${process.pid}`;
22const MANAGED = "/etc/ssh/sshd_config.d/01-chj-users.conf";
24function docker(args, options = {}) {
25 return execFileSync("docker", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...options });
27function inContainer(command, { allowFailure = false, user } = {}) {
28 const result = spawnSync("docker", ["exec", ...(user ? ["-u", user] : []), container, "sh", "-c", command], { encoding: "utf8" });
29 if (!allowFailure && result.status !== 0) throw new Error(`container command failed (${result.status}): ${command}\n${result.stderr}`);
30 return { code: result.status, stdout: result.stdout, stderr: result.stderr };
32// Real OpenSSH client login inside the container, so IPv4 (127.0.0.1) and IPv6 (::1) can be tested separately.
33function canLogin(user, address, { key, password } = {}) {
34 const common = "-o BatchMode=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -o LogLevel=ERROR";
35 const command = key
36 ? `ssh ${common} -o BatchMode=yes -o PreferredAuthentications=publickey -o IdentitiesOnly=yes -i ${key} ${user}@${address} true`
37 : `printf '#!/bin/sh\\necho %s\\n' '${password}' > /tmp/chj-askpass && chmod 700 /tmp/chj-askpass && SSH_ASKPASS=/tmp/chj-askpass SSH_ASKPASS_REQUIRE=force DISPLAY=none ssh ${common} -o NumberOfPasswordPrompts=1 -o PreferredAuthentications=password,keyboard-interactive -o PubkeyAuthentication=no ${user}@${address} true </dev/null`;
38 return inContainer(command, { allowFailure: true }).code === 0;
41class Profiles {
42 constructor() { this.profiles = new Map(); this.hostKeys = new Map(); this.passwords = new Map(); }
43 add(profile, password) { this.profiles.set(profile.id, profile); if (password) this.passwords.set(profile.id, password); }
44 get(id) { const profile = this.profiles.get(id); if (!profile) throw new Error("Profile not found"); return profile; }
45 getHostKey(host, port) { return this.hostKeys.get(`${host}:${port}`) || null; }
46 trustHostKey(host, port, fingerprint) { this.hostKeys.set(`${host}:${port}`, fingerprint); return { trusted: true }; }
47 getStoredPassword(id) { return this.passwords.get(id) || ""; }
48 markUsed() {}
51let port; let profiles; let manager; let service; let api; let keyDir;
52const sessions = {};
54async function connect(id, options = {}) {
55 try { await manager.connect({ sessionId: id, profileId: id, ...options }); }
56 catch (error) {
57 if (error.code !== "HOST_KEY_UNKNOWN") throw error;
58 manager.trustPending({ sessionId: id, profileId: id, fingerprint: error.fingerprint });
59 await manager.connect({ sessionId: id, profileId: id, ...options });
60 }
61 sessions[id] = id;
64function sshKeygen(name, comment) {
65 const file = path.join(keyDir, name);
66 execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f", file]);
67 return { privatePath: file, publicKey: fs.readFileSync(`${file}.pub`, "utf8").trim() };
70test("chj.users end-to-end on an isolated Linux container", { skip, timeout: 900000 }, async (t) => {
71 docker(["run", "-d", "--rm", "--name", container, "--sysctl", "net.ipv6.conf.all.disable_ipv6=0", "--sysctl", "net.ipv6.conf.lo.disable_ipv6=0", "-p", "127.0.0.1::22", image]);
72 t.after(() => { try { docker(["rm", "-f", container]); } catch {} });
73 port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop());
74 for (let attempt = 0; attempt < 50 && inContainer("test -s /run/sshd.pid", { allowFailure: true }).code !== 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 200));
75 keyDir = fs.mkdtempSync(path.join(os.tmpdir(), "chj-users-it-"));
76 t.after(() => fs.rmSync(keyDir, { recursive: true, force: true }));
77 const rootKey = sshKeygen("root", "root-session");
78 const nopassKey = sshKeygen("nopass", "nopass-session");
79 inContainer(`mkdir -p /root/.ssh && printf '%s\\n' '${rootKey.publicKey}' > /root/.ssh/authorized_keys && chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys`);
80 inContainer(`install -d -m 700 -o nopass -g nopass /home/nopass/.ssh && printf '%s\\n' '${nopassKey.publicKey}' > /home/nopass/.ssh/authorized_keys && chown nopass:nopass /home/nopass/.ssh/authorized_keys && chmod 600 /home/nopass/.ssh/authorized_keys`);
81 inContainer("grep -q '^PermitRootLogin' /etc/ssh/sshd_config || printf 'PermitRootLogin prohibit-password\\n' >> /etc/ssh/sshd_config; kill -HUP $(cat /run/sshd.pid)");
82 await new Promise((resolve) => setTimeout(resolve, 700));
83 const sshdVersion = inContainer("sshd -V 2>&1 | head -n 1", { allowFailure: true }).stdout.trim();
84 t.diagnostic(`image=${image} ${sshdVersion}`);
86 profiles = new Profiles();
87 const base = { host: "127.0.0.1", port, label: "it" };
88 profiles.add({ ...base, id: "admin", username: "admin", authMethod: "password" }, "AdminPass-1");
89 profiles.add({ ...base, id: "plain", username: "plain", authMethod: "password" }, "PlainPass-1");
90 profiles.add({ ...base, id: "nopass", username: "nopass", authMethod: "privateKey", privateKeyPath: nopassKey.privatePath });
91 profiles.add({ ...base, id: "root", username: "root", authMethod: "privateKey", privateKeyPath: rootKey.privatePath });
92 manager = new SessionManager({ profileService: profiles, latencyMonitor: { start: () => () => {} } });
93 const executor = new PrivilegedExecutor({ sessionManager: manager });
94 // Same wiring as production (src/main/index.js): platform detection decides the backend.
95 service = new UserAdminService({ sessionManager: manager, privilegedExecutor: executor, windowsService: { inventory: () => { throw new Error("Windows backend must not be used for Linux"); } }, detectPlatform });
96 api = createUserAdminApi(service);
97 t.after(() => manager.disconnectAll("test"));
98 for (const id of ["admin", "plain", "nopass", "root"]) await connect(id);
100 await t.test("privilege detection distinguishes root, NOPASSWD, password sudo and no sudo", async () => {
101 assert.equal((await detectPlatform(manager, "admin")).os, "linux");
102 assert.equal((await api.privileges("admin")).platform, "linux");
103 assert.equal((await api.privileges("root")).mode, "root");
104 assert.equal((await api.privileges("nopass")).mode, "sudo-nopasswd");
105 assert.equal((await api.privileges("admin")).mode, "sudo-password");
106 assert.equal((await api.privileges("plain")).mode, "sudo-password");
107 assert.match((await api.privileges("admin")).clientAddress, /^(\d+\.){3}\d+$/);
108 });
110 await t.test("read-only inventory works without sudo; privileged data needs the sudo password", async () => {
111 const plainInventory = await api.inventory("admin");
112 assert.equal(plainInventory.privileged, false);
113 const admin = plainInventory.users.find((user) => user.username === "admin");
114 assert.equal(admin.passwordState, "unknown");
115 assert.ok(admin.adminGroups.includes(image.includes("rocky") ? "wheel" : "sudo"));
116 await assert.rejects(() => api.inventory("admin", { privileged: true }), { code: "SUDO_PASSWORD_REQUIRED" });
117 await assert.rejects(() => api.inventory("admin", { privileged: true, sudoPassword: "wrong-password" }), { code: "SUDO_PASSWORD_INCORRECT" });
118 const inventory = await api.inventory("admin", { sudoPassword: "AdminPass-1" });
119 assert.equal(inventory.privileged, true);
120 const byName = Object.fromEntries(inventory.users.map((user) => [user.username, user]));
121 assert.equal(byName.admin.passwordState, "set");
122 assert.equal(byName.admin.sudo, "full");
123 assert.equal(byName.nopass.sudo, "full-nopasswd");
124 assert.equal(byName.plain.sudo, "none");
125 assert.equal(byName.root.protection, "root");
126 assert.equal(byName.admin.protection, "session");
127 assert.ok(byName.daemon?.system, "system accounts are separated");
128 assert.equal(JSON.stringify(inventory).includes("$6$"), false, "password hashes never leave the server");
129 assert.equal(JSON.stringify(inventory).includes("$y$"), false, "password hashes never leave the server");
130 });
132 await t.test("an account without sudo rights is told so and is not prompted again", async () => {
133 await assert.rejects(() => api.inventory("plain", { sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" });
134 await assert.rejects(() => api.manage("plain", { action: "lock", username: "nopass" }), { code: "SUDO_NOT_PERMITTED" });
135 assert.equal((await api.privileges("plain")).mode, "none");
136 assert.equal((await api.inventory("plain")).privileged, false, "read-only mode keeps working");
137 });
139 await t.test("account lifecycle with sudo password, groups, sudo, lock and expiry", async () => {
140 const auth = { sudoPassword: "AdminPass-1" };
141 const created = await api.manage("admin", { action: "create", username: "tester", password: "Tester-Pass-1", admin: true, comment: "Integration Tester", ...auth });
142 assert.equal(created.result, "ok", JSON.stringify(created.steps));
143 assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), "the new password works for SSH");
144 const tester = () => created.inventory.users.find((user) => user.username === "tester");
145 assert.equal(tester().sudo, "full");
146 assert.equal(tester().comment, "Integration Tester");
147 let result = await api.manage("admin", { action: "revokeAdmin", username: "tester", ...auth });
148 assert.equal(result.sudo, "none");
149 assert.equal(inContainer("id -nG tester").stdout.trim(), "tester");
150 result = await api.manage("admin", { action: "grantAdmin", username: "tester", ...auth });
151 assert.match(result.sudo, /^full/);
152 inContainer("getent group chjtest >/dev/null || groupadd chjtest");
153 await api.manage("admin", { action: "addGroup", username: "tester", group: "chjtest", ...auth });
154 assert.match(inContainer("id -nG tester").stdout, /chjtest/);
155 await api.manage("admin", { action: "removeGroup", username: "tester", group: "chjtest", ...auth });
156 assert.doesNotMatch(inContainer("id -nG tester").stdout, /chjtest/);
157 assert.match(inContainer("id -nG tester").stdout, /sudo|wheel/, "unrelated groups stay");
158 await assert.rejects(() => api.manage("admin", { action: "removeGroup", username: "tester", group: "tester", ...auth }), { code: "USERS_GROUP_PRIMARY" });
159 result = await api.manage("admin", { action: "lock", username: "tester", ...auth });
160 assert.equal(result.inventory.users.find((user) => user.username === "tester").passwordState, "locked");
161 assert.equal(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), false);
162 await api.manage("admin", { action: "unlock", username: "tester", ...auth });
163 result = await api.manage("admin", { action: "expire", username: "tester", ...auth });
164 assert.equal(result.inventory.users.find((user) => user.username === "tester").expired, true);
165 assert.equal(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), false, "an expired account cannot log in");
166 result = await api.manage("admin", { action: "unexpire", username: "tester", ...auth });
167 assert.equal(result.inventory.users.find((user) => user.username === "tester").expired, false);
168 assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }));
169 await api.manage("admin", { action: "setShell", username: "tester", shell: "/bin/sh", ...auth });
170 assert.equal(inContainer("getent passwd tester | cut -d: -f7").stdout.trim(), "/bin/sh");
171 await assert.rejects(() => api.manage("admin", { action: "setShell", username: "tester", shell: "/tmp/evil", ...auth }), { code: "USERS_SHELL_INVALID" });
172 await api.manage("admin", { action: "setShell", username: "tester", shell: "/bin/bash", ...auth });
173 });
175 await t.test("protected accounts, injection attempts and invalid input are refused", async () => {
176 const auth = { sudoPassword: "AdminPass-1" };
177 await assert.rejects(() => api.manage("admin", { action: "delete", username: "admin", ...auth }), { code: "USERS_PROTECTED_USER" });
178 await assert.rejects(() => api.manage("admin", { action: "lock", username: "root", ...auth }), { code: "USERS_PROTECTED_USER" });
179 await assert.rejects(() => api.manage("admin", { action: "delete", username: "daemon", ...auth }), { code: "USERS_PROTECTED_USER" });
180 for (const username of ["bob;id", "$(id)", "../etc", "-r", "Root", "a b", "x".repeat(33)]) {
181 await assert.rejects(() => api.manage("admin", { action: "create", username, ...auth }), { code: "USERS_NAME_INVALID" });
182 }
183 await assert.rejects(() => api.manage("admin", { action: "create", username: "evil", home: "/home/$(id)", ...auth }), { code: "USERS_HOME_INVALID" });
184 await assert.rejects(() => api.manage("admin", { action: "create", username: "evil", home: "/etc/evil", ...auth }), { code: "USERS_HOME_INVALID" });
185 await assert.rejects(() => api.manage("admin", { action: "create", username: "evil", comment: "x:0:0", ...auth }), { code: "USERS_COMMENT_INVALID" });
186 await assert.rejects(() => api.manage("admin", { action: "setPassword", username: "tester", password: "a\nroot:x", ...auth }), { code: "USERS_PASSWORD_INVALID" });
187 // The remote script validates again, even if Core were bypassed.
188 const executor = new PrivilegedExecutor({ sessionManager: manager });
189 await assert.rejects(() => executor.run("root", { script: REMOTE_SCRIPT, args: ["account", "lock", "x;touch /tmp/pwned"], errorPrefix: "USERS_" }), { code: "USERS_NAME_INVALID" });
190 await assert.rejects(() => executor.run("root", { script: REMOTE_SCRIPT, args: ["keys-write", "tester", "/etc/passwd", "absent"], frames: [{ tag: "DATA", data: "x" }], errorPrefix: "USERS_" }), { code: "USERS_KEYS_PATH_UNSUPPORTED" });
191 assert.equal(inContainer("test -e /tmp/pwned", { allowFailure: true }).code, 1);
192 assert.equal(inContainer("getent passwd evil", { allowFailure: true }).code, 2);
193 });
195 const testerKeyA = sshKeygen("testerA", "tester-a");
196 const testerKeyB = sshKeygen("testerB", "tester-b");
197 await t.test("authorized_keys: add and remove single Ed25519 keys without touching other lines", async () => {
198 const unrelated = "# managed by someone else\ncommand=\"/usr/bin/true\",from=\"10.0.0.0/8\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl legacy key\r\n";
199 inContainer(`install -d -m 700 -o tester -g tester /home/tester/.ssh && printf '%s' '${unrelated.replace(/'/g, "'\\''")}' > /home/tester/.ssh/authorized_keys && chown tester:tester /home/tester/.ssh/authorized_keys && chmod 600 /home/tester/.ssh/authorized_keys`);
200 const original = inContainer("base64 -w0 /home/tester/.ssh/authorized_keys").stdout.trim();
201 const auth = { sudoPassword: "AdminPass-1" };
202 for (const file of ["testerA", "testerB"]) docker(["cp", path.join(keyDir, file), `${container}:/tmp/${file}`]);
203 inContainer("chmod 600 /tmp/testerA /tmp/testerB");
204 let result = await api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyA.publicKey, ...auth });
205 result = await api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyB.publicKey, ...auth });
206 await assert.rejects(() => api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyB.publicKey, ...auth }), { code: "USERS_KEY_DUPLICATE" });
207 await assert.rejects(() => api.keys("admin", { action: "add", username: "tester", publicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ== x", ...auth }), { code: "USERS_KEY_TYPE_UNSUPPORTED" });
208 const file = result.detail.keys.files.find((candidate) => candidate.type === "file");
209 assert.equal(file.entries.filter((entry) => entry.valid).length, 3);
210 assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerA" }));
211 assert.ok(canLogin("tester", "::1", { key: "/tmp/testerB" }));
212 const keyA = file.entries.find((entry) => entry.comment === "tester-a");
213 result = await api.keys("admin", { action: "remove", username: "tester", path: file.path, line: keyA.line, fingerprint: keyA.fingerprint, ...auth });
214 assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerA" }), false);
215 assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }));
216 const after = Buffer.from(inContainer("base64 -w0 /home/tester/.ssh/authorized_keys").stdout.trim(), "base64").toString("latin1");
217 const expected = `${Buffer.from(original, "base64").toString("latin1")}${testerKeyB.publicKey}\n`;
218 assert.equal(after, expected, "unrelated keys, options, comments and CRLF stay byte-identical");
219 assert.equal(inContainer("stat -c '%U %a' /home/tester/.ssh/authorized_keys /home/tester/.ssh").stdout.trim(), "tester 600\ntester 700");
220 assert.equal(parseAuthorizedKeys(after).filter((entry) => entry.valid).length, 2);
221 // A symlinked authorized_keys is never written through.
222 inContainer("mv /home/tester/.ssh/authorized_keys /home/tester/.ssh/real_keys && ln -s real_keys /home/tester/.ssh/authorized_keys && chown -h tester:tester /home/tester/.ssh/authorized_keys");
223 await assert.rejects(() => api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyA.publicKey, ...auth }), { code: "USERS_KEYS_FILE_UNMANAGEABLE" });
224 inContainer("rm /home/tester/.ssh/authorized_keys && mv /home/tester/.ssh/real_keys /home/tester/.ssh/authorized_keys");
225 });
227 await t.test("the session account manages its own keys without sudo and cannot remove the key in use", async () => {
228 const extra = sshKeygen("nopassExtra", "nopass-extra");
229 const before = await api.detail("nopass", { username: "nopass" });
230 const inUse = before.keys.files[0].entries.find((entry) => entry.comment === "nopass-session");
231 await assert.rejects(() => api.keys("nopass", { action: "remove", username: "nopass", line: inUse.line, fingerprint: inUse.fingerprint }), { code: "USERS_KEY_IN_USE" });
232 const added = await api.keys("nopass", { action: "add", username: "nopass", publicKey: extra.publicKey });
233 assert.equal(added.detail.keys.files[0].entries.length, 2);
234 const extraEntry = added.detail.keys.files[0].entries.find((entry) => entry.comment === "nopass-extra");
235 await api.keys("nopass", { action: "remove", username: "nopass", line: extraEntry.line, fingerprint: extraEntry.fingerprint });
236 });
238 await t.test("per-user SSH policy: password, IPv4 and IPv6 are enforced separately and verified", async () => {
239 const auth = { sudoPassword: "AdminPass-1" };
240 assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }));
241 let result = await api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth });
242 assert.equal(result.applied, true);
243 assert.equal(result.detail.ssh.effective.ipv4.passwordAuthentication, "no");
244 assert.equal(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), false, "password login is disabled");
245 assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), "key login still works");
246 assert.ok(canLogin("admin", "127.0.0.1", { password: "AdminPass-1" }), "other accounts keep password login");
247 result = await api.sshPolicy("admin", { username: "tester", policy: { password: "no", ipv4: "deny" }, ...auth });
248 assert.equal(result.detail.ssh.effective.ipv4.allowed, false);
249 assert.equal(result.detail.ssh.effective.ipv6.allowed, true);
250 assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), false, "IPv4 denied");
251 assert.ok(canLogin("tester", "::1", { key: "/tmp/testerB" }), "IPv6 still allowed");
252 result = await api.sshPolicy("admin", { username: "tester", policy: { password: "no", ipv6: "deny" }, ...auth });
253 assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), "IPv4 allowed again");
254 assert.equal(canLogin("tester", "::1", { key: "/tmp/testerB" }), false, "IPv6 denied");
255 result = await api.sshPolicy("admin", { username: "tester", policy: { access: "deny" }, ...auth });
256 assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), false, "SSH fully disabled");
257 assert.equal(inContainer("getent passwd tester", { allowFailure: true }).code, 0, "the Linux account still exists");
258 result = await api.sshPolicy("admin", { username: "tester", policy: {}, ...auth });
259 assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "inherit everywhere removes the managed file");
260 assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), "global rules are inherited again");
261 assert.ok(canLogin("tester", "::1", { password: "Tester-Pass-1" }));
262 });
264 await t.test("existing Match rules with higher priority are detected and the change is rolled back", async () => {
265 const auth = { sudoPassword: "AdminPass-1" };
266 inContainer("printf 'Match User tester\\n\\tPasswordAuthentication yes\\n' > /etc/ssh/sshd_config.d/00-admin-rules.conf");
267 await assert.rejects(() => api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth }), { code: "USERS_SSH_POLICY_NOT_EFFECTIVE" });
268 assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "rolled back to the previous (absent) state");
269 assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }));
270 inContainer("rm -f /etc/ssh/sshd_config.d/00-admin-rules.conf");
271 // A global rule in another drop-in is inherited when CH-J says "inherit".
272 inContainer("printf 'PasswordAuthentication no\\n' > /etc/ssh/sshd_config.d/00-global.conf && kill -HUP $(cat /run/sshd.pid)");
273 await new Promise((resolve) => setTimeout(resolve, 700));
274 let detail = await api.detail("admin", { username: "tester", ...auth });
275 assert.equal(detail.ssh.effective.ipv4.passwordAuthentication, "no");
276 await api.sshPolicy("admin", { username: "tester", policy: { password: "yes" }, ...auth });
277 assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), "a per-user override works against the global rule");
278 await api.sshPolicy("admin", { username: "tester", policy: {}, ...auth });
279 inContainer("rm -f /etc/ssh/sshd_config.d/00-global.conf && kill -HUP $(cat /run/sshd.pid)");
280 await new Promise((resolve) => setTimeout(resolve, 700));
281 detail = await api.detail("admin", { username: "tester", ...auth });
282 assert.equal(detail.ssh.effective.ipv4.passwordAuthentication, "yes");
283 });
285 await t.test("lockout protection for the active session account", async () => {
286 const auth = { sudoPassword: "AdminPass-1" };
287 await assert.rejects(() => api.sshPolicy("admin", { username: "admin", policy: { password: "no" }, ...auth }), { code: "USERS_SSH_LOCKOUT_PREVENTED" });
288 await assert.rejects(() => api.sshPolicy("admin", { username: "admin", policy: { ipv4: "deny" }, ...auth }), { code: "USERS_SSH_LOCKOUT_PREVENTED" });
289 assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1);
290 assert.ok(canLogin("admin", "127.0.0.1", { password: "AdminPass-1" }));
291 // A key session may disable password login for itself; a fresh key login is probed before confirming.
292 const result = await api.sshPolicy("nopass", { username: "nopass", policy: { password: "no" } });
293 assert.equal(result.applied, true);
294 assert.equal(result.probed, true);
295 assert.equal(canLogin("nopass", "127.0.0.1", { password: "NopassPass-1" }), false);
296 await api.sshPolicy("nopass", { username: "nopass", policy: {} });
297 });
299 await t.test("invalid sshd configuration is never applied; disconnects and the watchdog roll back", async () => {
300 const auth = { sudoPassword: "AdminPass-1" };
301 inContainer("printf 'ThisIsNotAnOption yes\\n' > /etc/ssh/sshd_config.d/99-broken.conf");
302 await assert.rejects(() => api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth }), { code: /USERS_SSH_CONFIG_ALREADY_INVALID/ });
303 inContainer("rm -f /etc/ssh/sshd_config.d/99-broken.conf");
304 const executor = new PrivilegedExecutor({ sessionManager: manager });
305 const content = "# test\nDenyUsers tester\n";
306 // EOF before COMMIT: the session drops while the change is staged.
307 profiles.add({ ...profiles.get("root"), id: "root-drop" });
308 await connect("root-drop");
309 await assert.rejects(() => executor.run("root-drop", {
310 script: REMOTE_SCRIPT, args: ["ssh-apply", "absent", "60", "tester|192.0.2.10"], frames: [{ tag: "DATA", data: content }], errorPrefix: "USERS_", timeoutMs: 30000,
311 interact: async (io) => { await io.waitFor(/\x1eCHJ_STAGED\n/); await manager.disconnect("root-drop", "test-drop"); }
312 }));
313 await new Promise((resolve) => setTimeout(resolve, 1500));
314 assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "staged change restored after disconnect");
315 // Reloaded but never confirmed: the server-side watchdog restores and reloads.
316 const pending = executor.run("root", {
317 script: REMOTE_SCRIPT, args: ["ssh-apply", "absent", "3", "tester|192.0.2.10"], frames: [{ tag: "DATA", data: content }], errorPrefix: "USERS_", timeoutMs: 30000,
318 interact: async (io) => {
319 await io.waitFor(/\x1eCHJ_STAGED\n/); io.send("COMMIT"); await io.waitFor(/\x1eCHJ_RELOADED=/);
320 assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), false, "change is live before confirmation");
321 await new Promise((resolve) => setTimeout(resolve, 5000));
322 io.send("CONFIRM");
323 return (await io.waitFor(/\x1eCHJ_ROLLED_BACK=([^\n]*)\n/)).match[1];
324 }
325 });
326 assert.equal((await pending).interaction, "watchdog");
327 assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1);
328 assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), "access restored by watchdog");
329 // A manually edited managed file is never overwritten.
330 inContainer(`printf '# CH-J Server Manager - chj.users managed SSH access policy.\\nDenyUsers somebody\\n' > ${MANAGED}`);
331 await assert.rejects(() => api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth }), { code: "USERS_SSH_MANAGED_FILE_MODIFIED" });
332 inContainer(`rm -f ${MANAGED}`);
333 });
335 await t.test("root SSH: Ed25519 keys via sudo and a root session, PermitRootLogin, IPv4/IPv6 and protections", async () => {
336 const auth = { sudoPassword: "AdminPass-1" };
337 const vmKey = sshKeygen("rootVm", "root-vm-key");
338 docker(["cp", rootKey.privatePath, `${container}:/tmp/rootSession`]);
339 docker(["cp", vmKey.privatePath, `${container}:/tmp/rootVm`]);
340 inContainer("chmod 600 /tmp/rootSession /tmp/rootVm");
341 const before = inContainer("cat /root/.ssh/authorized_keys").stdout;
342 // A sudo administrator adds a separate Ed25519 key for root.
343 let result = await api.keys("admin", { action: "add", username: "root", publicKey: vmKey.publicKey, ...auth });
344 const file = result.detail.keys.files[0];
345 assert.equal(file.path, "/root/.ssh/authorized_keys");
346 assert.equal(inContainer("cat /root/.ssh/authorized_keys").stdout, `${before}${vmKey.publicKey}\n`, "existing root keys are kept");
347 assert.equal(inContainer("stat -c '%U %a' /root/.ssh /root/.ssh/authorized_keys").stdout.trim(), "root 700\nroot 600");
348 assert.ok(canLogin("root", "127.0.0.1", { key: "/tmp/rootVm" }), "root key login over IPv4");
349 assert.ok(canLogin("root", "::1", { key: "/tmp/rootVm" }), "root key login over IPv6");
350 assert.equal(canLogin("root", "127.0.0.1", { password: "anything" }), false, "no password login for root");
351 await assert.rejects(() => api.keys("plain", { action: "add", username: "root", publicKey: vmKey.publicKey, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" });
352 // PermitRootLogin through the CH-J drop-in, verified by sshd -T and real logins.
353 await assert.rejects(() => api.sshPolicy("admin", { username: "root", policy: { password: "yes" }, ...auth }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" });
354 result = await api.sshPolicy("admin", { username: "root", policy: { permitRootLogin: "no" }, ...auth });
355 assert.equal(result.applied, true);
356 assert.equal(canLogin("root", "127.0.0.1", { key: "/tmp/rootVm" }), false, "root SSH disabled");
357 assert.ok(canLogin("admin", "127.0.0.1", { password: "AdminPass-1" }), "other accounts unaffected");
358 result = await api.sshPolicy("admin", { username: "root", policy: { permitRootLogin: "prohibit-password", ipv6: "deny" }, ...auth });
359 assert.match(result.detail.ssh.effective.ipv4.permitRootLogin, /prohibit-password|without-password/);
360 assert.ok(canLogin("root", "127.0.0.1", { key: "/tmp/rootVm" }), "key-only root over IPv4");
361 assert.equal(canLogin("root", "::1", { key: "/tmp/rootVm" }), false, "root IPv6 denied separately");
362 // The direct root session manages its own keys without sudo and is protected against lockout.
363 const inUse = (await api.detail("root", { username: "root" })).keys.files[0].entries.find((entry) => entry.comment === "root-session");
364 await assert.rejects(() => api.keys("root", { action: "remove", username: "root", line: inUse.line, fingerprint: inUse.fingerprint }), { code: "USERS_KEY_IN_USE" });
365 await assert.rejects(() => api.sshPolicy("root", { username: "root", policy: { permitRootLogin: "no" } }), { code: /USERS_SSH_(LOCKOUT_PREVENTED|PROBE_CREDENTIALS_REQUIRED)/ });
366 result = await api.sshPolicy("admin", { username: "root", policy: {}, ...auth });
367 assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "root rules removed again");
368 const added = (await api.detail("root", { username: "root" })).keys.files[0].entries.find((entry) => entry.comment === "root-vm-key");
369 await api.keys("root", { action: "remove", username: "root", line: added.line, fingerprint: added.fingerprint });
370 assert.equal(inContainer("cat /root/.ssh/authorized_keys").stdout, before, "only the selected key was removed");
371 // Account protections stay in force.
372 await assert.rejects(() => api.manage("admin", { action: "delete", username: "root", ...auth }), { code: "USERS_PROTECTED_USER" });
373 await assert.rejects(() => api.manage("admin", { action: "setHome", username: "root", home: "/srv/root", ...auth }), { code: "USERS_PROTECTED_USER" });
374 await assert.rejects(() => api.manage("admin", { action: "revokeAdmin", username: "root", ...auth }), { code: "USERS_PROTECTED_USER" });
375 });
377 await t.test("root session, parallel sessions and deletion with SSH policy cleanup", async () => {
378 const [left, right] = await Promise.all([api.inventory("root"), api.inventory("nopass")]);
379 assert.equal(left.privileged, true); assert.equal(right.privileged, true);
380 await api.sshPolicy("root", { username: "tester", policy: { ipv6: "deny" } });
381 assert.match(inContainer(`cat ${MANAGED}`).stdout, /DenyUsers tester@::\/0/);
382 inContainer("pkill -u tester || true");
383 const result = await api.manage("root", { action: "delete", username: "tester", removeHome: true });
384 assert.equal(result.result, "ok", JSON.stringify(result));
385 assert.equal(inContainer("getent passwd tester", { allowFailure: true }).code, 2);
386 assert.equal(inContainer("test -d /home/tester", { allowFailure: true }).code, 1);
387 assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "policy of the deleted account removed");
388 assert.ok(canLogin("admin", "::1", { password: "AdminPass-1" }));
389 });
390});
SHA-256bc2673c8a4395ace44f215e6fbf9228d237a607e94ae496770066c4c4edaa7a4

CH-J Proprietary Software License 1.14 · Kod sumber disediakan di bawah CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah terma lesen atau memberikan hak tambahan.

SHA-256 arkiba9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Fail