"use strict"; // Destructive end-to-end tests of chj.users against a disposable Docker container. // Run explicitly: CHJ_USERS_INTEGRATION=1 [CHJ_USERS_IMAGE=chj-users-it-debian] node --test test/usersIntegration.test.js // The image is built from test/fixtures/usersIntegration. Never point this test at a real server. const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync, spawnSync } = require("node:child_process"); const { SessionManager } = require("../src/main/sessions/sessionManager"); const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec"); const { UserAdminService, REMOTE_SCRIPT, createUserAdminApi } = require("../src/main/users/userAdminService"); const { parseAuthorizedKeys } = require("../src/main/users/authorizedKeys"); const { detectPlatform } = require("../src/main/sessions/platformDetect"); const enabled = process.env.CHJ_USERS_INTEGRATION === "1"; const image = process.env.CHJ_USERS_IMAGE || "chj-users-it-debian"; const skip = enabled ? false : "Set CHJ_USERS_INTEGRATION=1 to run destructive tests in a disposable Docker container."; const container = `chj-users-it-${process.pid}`; const MANAGED = "/etc/ssh/sshd_config.d/01-chj-users.conf"; function docker(args, options = {}) { return execFileSync("docker", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...options }); } function inContainer(command, { allowFailure = false, user } = {}) { const result = spawnSync("docker", ["exec", ...(user ? ["-u", user] : []), container, "sh", "-c", command], { encoding: "utf8" }); if (!allowFailure && result.status !== 0) throw new Error(`container command failed (${result.status}): ${command}\n${result.stderr}`); return { code: result.status, stdout: result.stdout, stderr: result.stderr }; } // Real OpenSSH client login inside the container, so IPv4 (127.0.0.1) and IPv6 (::1) can be tested separately. function canLogin(user, address, { key, password } = {}) { const common = "-o BatchMode=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -o LogLevel=ERROR"; const command = key ? `ssh ${common} -o BatchMode=yes -o PreferredAuthentications=publickey -o IdentitiesOnly=yes -i ${key} ${user}@${address} true` : `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 { 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]); t.after(() => { try { docker(["rm", "-f", container]); } catch {} }); port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop()); 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)); keyDir = fs.mkdtempSync(path.join(os.tmpdir(), "chj-users-it-")); t.after(() => fs.rmSync(keyDir, { recursive: true, force: true })); const rootKey = sshKeygen("root", "root-session"); const nopassKey = sshKeygen("nopass", "nopass-session"); inContainer(`mkdir -p /root/.ssh && printf '%s\\n' '${rootKey.publicKey}' > /root/.ssh/authorized_keys && chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys`); 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`); inContainer("grep -q '^PermitRootLogin' /etc/ssh/sshd_config || printf 'PermitRootLogin prohibit-password\\n' >> /etc/ssh/sshd_config; kill -HUP $(cat /run/sshd.pid)"); await new Promise((resolve) => setTimeout(resolve, 700)); const sshdVersion = inContainer("sshd -V 2>&1 | head -n 1", { allowFailure: true }).stdout.trim(); t.diagnostic(`image=${image} ${sshdVersion}`); profiles = new Profiles(); const base = { host: "127.0.0.1", port, label: "it" }; profiles.add({ ...base, id: "admin", username: "admin", authMethod: "password" }, "AdminPass-1"); profiles.add({ ...base, id: "plain", username: "plain", authMethod: "password" }, "PlainPass-1"); profiles.add({ ...base, id: "nopass", username: "nopass", authMethod: "privateKey", privateKeyPath: nopassKey.privatePath }); profiles.add({ ...base, id: "root", username: "root", authMethod: "privateKey", privateKeyPath: rootKey.privatePath }); manager = new SessionManager({ profileService: profiles, latencyMonitor: { start: () => () => {} } }); const executor = new PrivilegedExecutor({ sessionManager: manager }); // Same wiring as production (src/main/index.js): platform detection decides the backend. service = new UserAdminService({ sessionManager: manager, privilegedExecutor: executor, windowsService: { inventory: () => { throw new Error("Windows backend must not be used for Linux"); } }, detectPlatform }); api = createUserAdminApi(service); t.after(() => manager.disconnectAll("test")); for (const id of ["admin", "plain", "nopass", "root"]) await connect(id); await t.test("privilege detection distinguishes root, NOPASSWD, password sudo and no sudo", async () => { assert.equal((await detectPlatform(manager, "admin")).os, "linux"); assert.equal((await api.privileges("admin")).platform, "linux"); assert.equal((await api.privileges("root")).mode, "root"); assert.equal((await api.privileges("nopass")).mode, "sudo-nopasswd"); assert.equal((await api.privileges("admin")).mode, "sudo-password"); assert.equal((await api.privileges("plain")).mode, "sudo-password"); assert.match((await api.privileges("admin")).clientAddress, /^(\d+\.){3}\d+$/); }); await t.test("read-only inventory works without sudo; privileged data needs the sudo password", async () => { const plainInventory = await api.inventory("admin"); assert.equal(plainInventory.privileged, false); const admin = plainInventory.users.find((user) => user.username === "admin"); assert.equal(admin.passwordState, "unknown"); assert.ok(admin.adminGroups.includes(image.includes("rocky") ? "wheel" : "sudo")); await assert.rejects(() => api.inventory("admin", { privileged: true }), { code: "SUDO_PASSWORD_REQUIRED" }); await assert.rejects(() => api.inventory("admin", { privileged: true, sudoPassword: "wrong-password" }), { code: "SUDO_PASSWORD_INCORRECT" }); const inventory = await api.inventory("admin", { sudoPassword: "AdminPass-1" }); assert.equal(inventory.privileged, true); const byName = Object.fromEntries(inventory.users.map((user) => [user.username, user])); assert.equal(byName.admin.passwordState, "set"); assert.equal(byName.admin.sudo, "full"); assert.equal(byName.nopass.sudo, "full-nopasswd"); assert.equal(byName.plain.sudo, "none"); assert.equal(byName.root.protection, "root"); assert.equal(byName.admin.protection, "session"); assert.ok(byName.daemon?.system, "system accounts are separated"); assert.equal(JSON.stringify(inventory).includes("$6$"), false, "password hashes never leave the server"); assert.equal(JSON.stringify(inventory).includes("$y$"), false, "password hashes never leave the server"); }); await t.test("an account without sudo rights is told so and is not prompted again", async () => { await assert.rejects(() => api.inventory("plain", { sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" }); await assert.rejects(() => api.manage("plain", { action: "lock", username: "nopass" }), { code: "SUDO_NOT_PERMITTED" }); assert.equal((await api.privileges("plain")).mode, "none"); assert.equal((await api.inventory("plain")).privileged, false, "read-only mode keeps working"); }); await t.test("account lifecycle with sudo password, groups, sudo, lock and expiry", async () => { const auth = { sudoPassword: "AdminPass-1" }; const created = await api.manage("admin", { action: "create", username: "tester", password: "Tester-Pass-1", admin: true, comment: "Integration Tester", ...auth }); assert.equal(created.result, "ok", JSON.stringify(created.steps)); assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), "the new password works for SSH"); const tester = () => created.inventory.users.find((user) => user.username === "tester"); assert.equal(tester().sudo, "full"); assert.equal(tester().comment, "Integration Tester"); let result = await api.manage("admin", { action: "revokeAdmin", username: "tester", ...auth }); assert.equal(result.sudo, "none"); assert.equal(inContainer("id -nG tester").stdout.trim(), "tester"); result = await api.manage("admin", { action: "grantAdmin", username: "tester", ...auth }); assert.match(result.sudo, /^full/); inContainer("getent group chjtest >/dev/null || groupadd chjtest"); await api.manage("admin", { action: "addGroup", username: "tester", group: "chjtest", ...auth }); assert.match(inContainer("id -nG tester").stdout, /chjtest/); await api.manage("admin", { action: "removeGroup", username: "tester", group: "chjtest", ...auth }); assert.doesNotMatch(inContainer("id -nG tester").stdout, /chjtest/); assert.match(inContainer("id -nG tester").stdout, /sudo|wheel/, "unrelated groups stay"); await assert.rejects(() => api.manage("admin", { action: "removeGroup", username: "tester", group: "tester", ...auth }), { code: "USERS_GROUP_PRIMARY" }); result = await api.manage("admin", { action: "lock", username: "tester", ...auth }); assert.equal(result.inventory.users.find((user) => user.username === "tester").passwordState, "locked"); assert.equal(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), false); await api.manage("admin", { action: "unlock", username: "tester", ...auth }); result = await api.manage("admin", { action: "expire", username: "tester", ...auth }); assert.equal(result.inventory.users.find((user) => user.username === "tester").expired, true); assert.equal(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), false, "an expired account cannot log in"); result = await api.manage("admin", { action: "unexpire", username: "tester", ...auth }); assert.equal(result.inventory.users.find((user) => user.username === "tester").expired, false); assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" })); await api.manage("admin", { action: "setShell", username: "tester", shell: "/bin/sh", ...auth }); assert.equal(inContainer("getent passwd tester | cut -d: -f7").stdout.trim(), "/bin/sh"); await assert.rejects(() => api.manage("admin", { action: "setShell", username: "tester", shell: "/tmp/evil", ...auth }), { code: "USERS_SHELL_INVALID" }); await api.manage("admin", { action: "setShell", username: "tester", shell: "/bin/bash", ...auth }); }); await t.test("protected accounts, injection attempts and invalid input are refused", async () => { const auth = { sudoPassword: "AdminPass-1" }; await assert.rejects(() => api.manage("admin", { action: "delete", username: "admin", ...auth }), { code: "USERS_PROTECTED_USER" }); await assert.rejects(() => api.manage("admin", { action: "lock", username: "root", ...auth }), { code: "USERS_PROTECTED_USER" }); await assert.rejects(() => api.manage("admin", { action: "delete", username: "daemon", ...auth }), { code: "USERS_PROTECTED_USER" }); for (const username of ["bob;id", "$(id)", "../etc", "-r", "Root", "a b", "x".repeat(33)]) { await assert.rejects(() => api.manage("admin", { action: "create", username, ...auth }), { code: "USERS_NAME_INVALID" }); } await assert.rejects(() => api.manage("admin", { action: "create", username: "evil", home: "/home/$(id)", ...auth }), { code: "USERS_HOME_INVALID" }); await assert.rejects(() => api.manage("admin", { action: "create", username: "evil", home: "/etc/evil", ...auth }), { code: "USERS_HOME_INVALID" }); await assert.rejects(() => api.manage("admin", { action: "create", username: "evil", comment: "x:0:0", ...auth }), { code: "USERS_COMMENT_INVALID" }); await assert.rejects(() => api.manage("admin", { action: "setPassword", username: "tester", password: "a\nroot:x", ...auth }), { code: "USERS_PASSWORD_INVALID" }); // The remote script validates again, even if Core were bypassed. const executor = new PrivilegedExecutor({ sessionManager: manager }); await assert.rejects(() => executor.run("root", { script: REMOTE_SCRIPT, args: ["account", "lock", "x;touch /tmp/pwned"], errorPrefix: "USERS_" }), { code: "USERS_NAME_INVALID" }); 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" }); assert.equal(inContainer("test -e /tmp/pwned", { allowFailure: true }).code, 1); assert.equal(inContainer("getent passwd evil", { allowFailure: true }).code, 2); }); const testerKeyA = sshKeygen("testerA", "tester-a"); const testerKeyB = sshKeygen("testerB", "tester-b"); await t.test("authorized_keys: add and remove single Ed25519 keys without touching other lines", async () => { const unrelated = "# managed by someone else\ncommand=\"/usr/bin/true\",from=\"10.0.0.0/8\" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl legacy key\r\n"; 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`); const original = inContainer("base64 -w0 /home/tester/.ssh/authorized_keys").stdout.trim(); const auth = { sudoPassword: "AdminPass-1" }; for (const file of ["testerA", "testerB"]) docker(["cp", path.join(keyDir, file), `${container}:/tmp/${file}`]); inContainer("chmod 600 /tmp/testerA /tmp/testerB"); let result = await api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyA.publicKey, ...auth }); result = await api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyB.publicKey, ...auth }); await assert.rejects(() => api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyB.publicKey, ...auth }), { code: "USERS_KEY_DUPLICATE" }); await assert.rejects(() => api.keys("admin", { action: "add", username: "tester", publicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ== x", ...auth }), { code: "USERS_KEY_TYPE_UNSUPPORTED" }); const file = result.detail.keys.files.find((candidate) => candidate.type === "file"); assert.equal(file.entries.filter((entry) => entry.valid).length, 3); assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerA" })); assert.ok(canLogin("tester", "::1", { key: "/tmp/testerB" })); const keyA = file.entries.find((entry) => entry.comment === "tester-a"); result = await api.keys("admin", { action: "remove", username: "tester", path: file.path, line: keyA.line, fingerprint: keyA.fingerprint, ...auth }); assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerA" }), false); assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" })); const after = Buffer.from(inContainer("base64 -w0 /home/tester/.ssh/authorized_keys").stdout.trim(), "base64").toString("latin1"); const expected = `${Buffer.from(original, "base64").toString("latin1")}${testerKeyB.publicKey}\n`; assert.equal(after, expected, "unrelated keys, options, comments and CRLF stay byte-identical"); assert.equal(inContainer("stat -c '%U %a' /home/tester/.ssh/authorized_keys /home/tester/.ssh").stdout.trim(), "tester 600\ntester 700"); assert.equal(parseAuthorizedKeys(after).filter((entry) => entry.valid).length, 2); // A symlinked authorized_keys is never written through. 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"); await assert.rejects(() => api.keys("admin", { action: "add", username: "tester", publicKey: testerKeyA.publicKey, ...auth }), { code: "USERS_KEYS_FILE_UNMANAGEABLE" }); inContainer("rm /home/tester/.ssh/authorized_keys && mv /home/tester/.ssh/real_keys /home/tester/.ssh/authorized_keys"); }); await t.test("the session account manages its own keys without sudo and cannot remove the key in use", async () => { const extra = sshKeygen("nopassExtra", "nopass-extra"); const before = await api.detail("nopass", { username: "nopass" }); const inUse = before.keys.files[0].entries.find((entry) => entry.comment === "nopass-session"); await assert.rejects(() => api.keys("nopass", { action: "remove", username: "nopass", line: inUse.line, fingerprint: inUse.fingerprint }), { code: "USERS_KEY_IN_USE" }); const added = await api.keys("nopass", { action: "add", username: "nopass", publicKey: extra.publicKey }); assert.equal(added.detail.keys.files[0].entries.length, 2); const extraEntry = added.detail.keys.files[0].entries.find((entry) => entry.comment === "nopass-extra"); await api.keys("nopass", { action: "remove", username: "nopass", line: extraEntry.line, fingerprint: extraEntry.fingerprint }); }); await t.test("per-user SSH policy: password, IPv4 and IPv6 are enforced separately and verified", async () => { const auth = { sudoPassword: "AdminPass-1" }; assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" })); let result = await api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth }); assert.equal(result.applied, true); assert.equal(result.detail.ssh.effective.ipv4.passwordAuthentication, "no"); assert.equal(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), false, "password login is disabled"); assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), "key login still works"); assert.ok(canLogin("admin", "127.0.0.1", { password: "AdminPass-1" }), "other accounts keep password login"); result = await api.sshPolicy("admin", { username: "tester", policy: { password: "no", ipv4: "deny" }, ...auth }); assert.equal(result.detail.ssh.effective.ipv4.allowed, false); assert.equal(result.detail.ssh.effective.ipv6.allowed, true); assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), false, "IPv4 denied"); assert.ok(canLogin("tester", "::1", { key: "/tmp/testerB" }), "IPv6 still allowed"); result = await api.sshPolicy("admin", { username: "tester", policy: { password: "no", ipv6: "deny" }, ...auth }); assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), "IPv4 allowed again"); assert.equal(canLogin("tester", "::1", { key: "/tmp/testerB" }), false, "IPv6 denied"); result = await api.sshPolicy("admin", { username: "tester", policy: { access: "deny" }, ...auth }); assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), false, "SSH fully disabled"); assert.equal(inContainer("getent passwd tester", { allowFailure: true }).code, 0, "the Linux account still exists"); result = await api.sshPolicy("admin", { username: "tester", policy: {}, ...auth }); assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "inherit everywhere removes the managed file"); assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), "global rules are inherited again"); assert.ok(canLogin("tester", "::1", { password: "Tester-Pass-1" })); }); await t.test("existing Match rules with higher priority are detected and the change is rolled back", async () => { const auth = { sudoPassword: "AdminPass-1" }; inContainer("printf 'Match User tester\\n\\tPasswordAuthentication yes\\n' > /etc/ssh/sshd_config.d/00-admin-rules.conf"); await assert.rejects(() => api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth }), { code: "USERS_SSH_POLICY_NOT_EFFECTIVE" }); assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "rolled back to the previous (absent) state"); assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" })); inContainer("rm -f /etc/ssh/sshd_config.d/00-admin-rules.conf"); // A global rule in another drop-in is inherited when CH-J says "inherit". inContainer("printf 'PasswordAuthentication no\\n' > /etc/ssh/sshd_config.d/00-global.conf && kill -HUP $(cat /run/sshd.pid)"); await new Promise((resolve) => setTimeout(resolve, 700)); let detail = await api.detail("admin", { username: "tester", ...auth }); assert.equal(detail.ssh.effective.ipv4.passwordAuthentication, "no"); await api.sshPolicy("admin", { username: "tester", policy: { password: "yes" }, ...auth }); assert.ok(canLogin("tester", "127.0.0.1", { password: "Tester-Pass-1" }), "a per-user override works against the global rule"); await api.sshPolicy("admin", { username: "tester", policy: {}, ...auth }); inContainer("rm -f /etc/ssh/sshd_config.d/00-global.conf && kill -HUP $(cat /run/sshd.pid)"); await new Promise((resolve) => setTimeout(resolve, 700)); detail = await api.detail("admin", { username: "tester", ...auth }); assert.equal(detail.ssh.effective.ipv4.passwordAuthentication, "yes"); }); await t.test("lockout protection for the active session account", async () => { const auth = { sudoPassword: "AdminPass-1" }; await assert.rejects(() => api.sshPolicy("admin", { username: "admin", policy: { password: "no" }, ...auth }), { code: "USERS_SSH_LOCKOUT_PREVENTED" }); await assert.rejects(() => api.sshPolicy("admin", { username: "admin", policy: { ipv4: "deny" }, ...auth }), { code: "USERS_SSH_LOCKOUT_PREVENTED" }); assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1); assert.ok(canLogin("admin", "127.0.0.1", { password: "AdminPass-1" })); // A key session may disable password login for itself; a fresh key login is probed before confirming. const result = await api.sshPolicy("nopass", { username: "nopass", policy: { password: "no" } }); assert.equal(result.applied, true); assert.equal(result.probed, true); assert.equal(canLogin("nopass", "127.0.0.1", { password: "NopassPass-1" }), false); await api.sshPolicy("nopass", { username: "nopass", policy: {} }); }); await t.test("invalid sshd configuration is never applied; disconnects and the watchdog roll back", async () => { const auth = { sudoPassword: "AdminPass-1" }; inContainer("printf 'ThisIsNotAnOption yes\\n' > /etc/ssh/sshd_config.d/99-broken.conf"); await assert.rejects(() => api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth }), { code: /USERS_SSH_CONFIG_ALREADY_INVALID/ }); inContainer("rm -f /etc/ssh/sshd_config.d/99-broken.conf"); const executor = new PrivilegedExecutor({ sessionManager: manager }); const content = "# test\nDenyUsers tester\n"; // EOF before COMMIT: the session drops while the change is staged. profiles.add({ ...profiles.get("root"), id: "root-drop" }); await connect("root-drop"); await assert.rejects(() => executor.run("root-drop", { script: REMOTE_SCRIPT, args: ["ssh-apply", "absent", "60", "tester|192.0.2.10"], frames: [{ tag: "DATA", data: content }], errorPrefix: "USERS_", timeoutMs: 30000, interact: async (io) => { await io.waitFor(/\x1eCHJ_STAGED\n/); await manager.disconnect("root-drop", "test-drop"); } })); await new Promise((resolve) => setTimeout(resolve, 1500)); assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "staged change restored after disconnect"); // Reloaded but never confirmed: the server-side watchdog restores and reloads. const pending = executor.run("root", { script: REMOTE_SCRIPT, args: ["ssh-apply", "absent", "3", "tester|192.0.2.10"], frames: [{ tag: "DATA", data: content }], errorPrefix: "USERS_", timeoutMs: 30000, interact: async (io) => { await io.waitFor(/\x1eCHJ_STAGED\n/); io.send("COMMIT"); await io.waitFor(/\x1eCHJ_RELOADED=/); assert.equal(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), false, "change is live before confirmation"); await new Promise((resolve) => setTimeout(resolve, 5000)); io.send("CONFIRM"); return (await io.waitFor(/\x1eCHJ_ROLLED_BACK=([^\n]*)\n/)).match[1]; } }); assert.equal((await pending).interaction, "watchdog"); assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1); assert.ok(canLogin("tester", "127.0.0.1", { key: "/tmp/testerB" }), "access restored by watchdog"); // A manually edited managed file is never overwritten. inContainer(`printf '# CH-J Server Manager - chj.users managed SSH access policy.\\nDenyUsers somebody\\n' > ${MANAGED}`); await assert.rejects(() => api.sshPolicy("admin", { username: "tester", policy: { password: "no" }, ...auth }), { code: "USERS_SSH_MANAGED_FILE_MODIFIED" }); inContainer(`rm -f ${MANAGED}`); }); await t.test("root SSH: Ed25519 keys via sudo and a root session, PermitRootLogin, IPv4/IPv6 and protections", async () => { const auth = { sudoPassword: "AdminPass-1" }; const vmKey = sshKeygen("rootVm", "root-vm-key"); docker(["cp", rootKey.privatePath, `${container}:/tmp/rootSession`]); docker(["cp", vmKey.privatePath, `${container}:/tmp/rootVm`]); inContainer("chmod 600 /tmp/rootSession /tmp/rootVm"); const before = inContainer("cat /root/.ssh/authorized_keys").stdout; // A sudo administrator adds a separate Ed25519 key for root. let result = await api.keys("admin", { action: "add", username: "root", publicKey: vmKey.publicKey, ...auth }); const file = result.detail.keys.files[0]; assert.equal(file.path, "/root/.ssh/authorized_keys"); assert.equal(inContainer("cat /root/.ssh/authorized_keys").stdout, `${before}${vmKey.publicKey}\n`, "existing root keys are kept"); assert.equal(inContainer("stat -c '%U %a' /root/.ssh /root/.ssh/authorized_keys").stdout.trim(), "root 700\nroot 600"); assert.ok(canLogin("root", "127.0.0.1", { key: "/tmp/rootVm" }), "root key login over IPv4"); assert.ok(canLogin("root", "::1", { key: "/tmp/rootVm" }), "root key login over IPv6"); assert.equal(canLogin("root", "127.0.0.1", { password: "anything" }), false, "no password login for root"); await assert.rejects(() => api.keys("plain", { action: "add", username: "root", publicKey: vmKey.publicKey, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" }); // PermitRootLogin through the CH-J drop-in, verified by sshd -T and real logins. await assert.rejects(() => api.sshPolicy("admin", { username: "root", policy: { password: "yes" }, ...auth }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" }); result = await api.sshPolicy("admin", { username: "root", policy: { permitRootLogin: "no" }, ...auth }); assert.equal(result.applied, true); assert.equal(canLogin("root", "127.0.0.1", { key: "/tmp/rootVm" }), false, "root SSH disabled"); assert.ok(canLogin("admin", "127.0.0.1", { password: "AdminPass-1" }), "other accounts unaffected"); result = await api.sshPolicy("admin", { username: "root", policy: { permitRootLogin: "prohibit-password", ipv6: "deny" }, ...auth }); assert.match(result.detail.ssh.effective.ipv4.permitRootLogin, /prohibit-password|without-password/); assert.ok(canLogin("root", "127.0.0.1", { key: "/tmp/rootVm" }), "key-only root over IPv4"); assert.equal(canLogin("root", "::1", { key: "/tmp/rootVm" }), false, "root IPv6 denied separately"); // The direct root session manages its own keys without sudo and is protected against lockout. const inUse = (await api.detail("root", { username: "root" })).keys.files[0].entries.find((entry) => entry.comment === "root-session"); await assert.rejects(() => api.keys("root", { action: "remove", username: "root", line: inUse.line, fingerprint: inUse.fingerprint }), { code: "USERS_KEY_IN_USE" }); await assert.rejects(() => api.sshPolicy("root", { username: "root", policy: { permitRootLogin: "no" } }), { code: /USERS_SSH_(LOCKOUT_PREVENTED|PROBE_CREDENTIALS_REQUIRED)/ }); result = await api.sshPolicy("admin", { username: "root", policy: {}, ...auth }); assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "root rules removed again"); const added = (await api.detail("root", { username: "root" })).keys.files[0].entries.find((entry) => entry.comment === "root-vm-key"); await api.keys("root", { action: "remove", username: "root", line: added.line, fingerprint: added.fingerprint }); assert.equal(inContainer("cat /root/.ssh/authorized_keys").stdout, before, "only the selected key was removed"); // Account protections stay in force. await assert.rejects(() => api.manage("admin", { action: "delete", username: "root", ...auth }), { code: "USERS_PROTECTED_USER" }); await assert.rejects(() => api.manage("admin", { action: "setHome", username: "root", home: "/srv/root", ...auth }), { code: "USERS_PROTECTED_USER" }); await assert.rejects(() => api.manage("admin", { action: "revokeAdmin", username: "root", ...auth }), { code: "USERS_PROTECTED_USER" }); }); await t.test("root session, parallel sessions and deletion with SSH policy cleanup", async () => { const [left, right] = await Promise.all([api.inventory("root"), api.inventory("nopass")]); assert.equal(left.privileged, true); assert.equal(right.privileged, true); await api.sshPolicy("root", { username: "tester", policy: { ipv6: "deny" } }); assert.match(inContainer(`cat ${MANAGED}`).stdout, /DenyUsers tester@::\/0/); inContainer("pkill -u tester || true"); const result = await api.manage("root", { action: "delete", username: "tester", removeHome: true }); assert.equal(result.result, "ok", JSON.stringify(result)); assert.equal(inContainer("getent passwd tester", { allowFailure: true }).code, 2); assert.equal(inContainer("test -d /home/tester", { allowFailure: true }).code, 1); assert.equal(inContainer(`test -e ${MANAGED}`, { allowFailure: true }).code, 1, "policy of the deleted account removed"); assert.ok(canLogin("admin", "::1", { password: "AdminPass-1" })); }); });