Δημοσίευση πηγής
Κατεβάστε την πηγή ZIP
CH-J Server Manager
Περιηγηθείτε σε καταλόγους και αρχεία για μια συγκεκριμένη έκδοση εφαρμογών.
CH-J / Server Manager
377 αρχεία
core-20261019T111616Z-160a00bfcddc
Προστέθηκε: · Χτισμένο:
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.js5
// The image is built from test/fixtures/usersIntegration. Never point this test at a real server.6
const test = require("node:test");7
const assert = require("node:assert/strict");8
const fs = require("node:fs");9
const os = require("node:os");10
const path = require("node:path");11
const { execFileSync, spawnSync } = require("node:child_process");12
const { SessionManager } = require("../src/main/sessions/sessionManager");13
const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec");14
const { UserAdminService, REMOTE_SCRIPT, createUserAdminApi } = require("../src/main/users/userAdminService");15
const { parseAuthorizedKeys } = require("../src/main/users/authorizedKeys");16
const { detectPlatform } = require("../src/main/sessions/platformDetect");18
const enabled = process.env.CHJ_USERS_INTEGRATION === "1";19
const image = process.env.CHJ_USERS_IMAGE || "chj-users-it-debian";20
const skip = enabled ? false : "Set CHJ_USERS_INTEGRATION=1 to run destructive tests in a disposable Docker container.";21
const container = `chj-users-it-${process.pid}`;22
const MANAGED = "/etc/ssh/sshd_config.d/01-chj-users.conf";24
function docker(args, options = {}) {25
return execFileSync("docker", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...options });26
}27
function 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 };31
}32
// Real OpenSSH client login inside the container, so IPv4 (127.0.0.1) and IPv6 (::1) can be tested separately.33
function 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 = key36
? `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;39
}41
class 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() {}49
}51
let port; let profiles; let manager; let service; let api; let keyDir;52
const sessions = {};54
async 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;62
}64
function 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() };68
}70
test("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-256
bc2673c8a4395ace44f215e6fbf9228d237a607e94ae496770066c4c4edaa7a4CH-J Proprietary Software License 1.14 · Η πηγή παρέχεται στο CH-J Proprietary Software License 1.14. Η διαθεσιμότητά του δεν αλλάζει τους όρους της άδειας χρήσης ούτε χορηγεί πρόσθετα δικαιώματα.
Archive SHA-256
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a