Published source
Download source ZIP
CH-J Server Manager
Browse directories and files for a specific application release.
CH-J / Server Manager
368 files
core-20261017T201717Z-057348c4a70f
Added: Date not recorded · Built:
1
"use strict";3
const test = require("node:test");4
const assert = require("node:assert/strict");5
const fs = require("node:fs");6
const path = require("node:path");7
const { EventEmitter } = require("node:events");8
const { SessionManager } = require("../src/main/sessions/sessionManager");9
const { PrivilegedExecutor, SUDO_PROMPT } = require("../src/main/sessions/privilegedExec");10
const keys = require("../src/main/users/authorizedKeys");11
const policy = require("../src/main/users/sshPolicy");12
const { UserAdminService, validateComment, validateHome, validateName, validateShell, splitSections } = require("../src/main/users/userAdminService");14
// Public test keys; fingerprints were computed independently with ssh-keygen -lf.15
const KEY_A = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMtHrhcqui+RtemmVnYdQeb4FFPCnLpDGHdbW1x27n3f fixture-a";16
const KEY_B = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDxTAuuNqjy7bQQmrG2sNb9sQ3D7YDvUolTWt9PplroB fixture-b";17
const KEY_RSA = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgoqY+n4WTJHhQDDG3/Wq2ikgvEMDAEqf659oJvPxoT1786rRE8b1pA/TO7HdR3huqWVEOFgTtsasnwzzYbtk30CsrjdnfIYDt8g6wQcEirkZ4zlhX7SDLmgytRytIEawCsPruj31gh6YoYKTr8XV2TxZtBlkZCbG47+MYUz7tNw== fixture-rsa";18
const FP_A = "SHA256:4wwpzucK1K7t/mhInHzZAspG1n28pLehytTkdkEty+4";19
const FP_B = "SHA256:YbW5raLxpO5Bw7+M/3TL7Oe13zRU0AJWZrJJ/UIvGWA";20
const FP_RSA = "SHA256:XU/sToLGlIj0r/7pEWX2OmVd/wVtkA2KhY4OOxKg48g";22
test("authorized_keys parsing matches ssh-keygen fingerprints and keeps options and comments", () => {23
const content = `# comment line\r\n${KEY_RSA}\r\ncommand="echo a b",from="10.0.0.0/8" ${KEY_A}\n\ngarbage line here\n`;24
const entries = keys.parseAuthorizedKeys(content);25
assert.deepEqual(entries.map((entry) => [entry.line, entry.valid, entry.fingerprint || null]), [[2, true, FP_RSA], [3, true, FP_A], [5, false, null]]);26
assert.equal(entries[1].options, 'command="echo a b",from="10.0.0.0/8"');27
assert.equal(entries[1].comment, "fixture-a");28
assert.equal(entries[1].ed25519, true);29
assert.equal(entries[0].ed25519, false);30
});32
test("adding and removing a key changes exactly one line and preserves all other bytes", () => {33
const original = Buffer.from(`# keep\r\n${KEY_RSA}\r\nno-pty ${KEY_A}`, "latin1");34
const key = keys.normalizePublicKey(KEY_B);35
assert.equal(key.fingerprint, FP_B);36
const added = keys.appendKey(original, key);37
assert.equal(added.toString("latin1"), `${original.toString("latin1")}\n${KEY_B}\n`);38
assert.throws(() => keys.appendKey(added, keys.normalizePublicKey(KEY_B)), { code: "USERS_KEY_DUPLICATE" });39
const entry = keys.parseAuthorizedKeys(added).find((candidate) => candidate.fingerprint === FP_A);40
const removed = keys.removeKey(added, entry.line, FP_A);41
assert.equal(removed.toString("latin1"), `# keep\r\n${KEY_RSA}\r\n${KEY_B}\n`);42
assert.throws(() => keys.removeKey(added, entry.line, FP_B), { code: "USERS_KEY_NOT_FOUND" });43
assert.throws(() => keys.removeKey(added, 99, FP_A), { code: "USERS_KEY_NOT_FOUND" });44
// UTF-8 comments survive the latin1 byte-preserving round trip.45
const utf8 = keys.appendKey(Buffer.alloc(0), keys.normalizePublicKey(KEY_A, "Jiří Novák"));46
assert.equal(utf8.toString("utf8"), `${KEY_A.replace("fixture-a", "Jiří Novák")}\n`);47
assert.equal(keys.parseAuthorizedKeys(utf8)[0].comment, "Jiří Novák");48
});50
test("only one plain Ed25519 public key is accepted for upload", () => {51
assert.throws(() => keys.normalizePublicKey(KEY_RSA), { code: "USERS_KEY_TYPE_UNSUPPORTED" });52
assert.throws(() => keys.normalizePublicKey(`${KEY_A}\n${KEY_B}`), { code: "USERS_KEY_INVALID" });53
assert.throws(() => keys.normalizePublicKey(`command="id" ${KEY_A}`), { code: "USERS_KEY_INVALID" });54
assert.throws(() => keys.normalizePublicKey("-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----"), { code: /USERS_KEY_(PRIVATE|INVALID)/ });55
assert.throws(() => keys.normalizePublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMtH x"), { code: "USERS_KEY_INVALID" });56
assert.throws(() => keys.normalizePublicKey(KEY_A.replace("AAAAC3NzaC1lZDI1NTE5", "AAAAB3NzaC1yc2EAAAAD")), { code: "USERS_KEY_INVALID" });57
assert.equal(keys.normalizePublicKey(`${KEY_A}\r\n`).fingerprint, FP_A);58
});60
test("managed sshd policy is generated deterministically and manual edits are detected", () => {61
const policies = new Map([62
["bob", policy.normalizePolicy({ ipv6: "deny", password: "no" })],63
["alice", policy.normalizePolicy({ access: "deny" })],64
["carol", policy.normalizePolicy({})]65
]);66
const text = policy.generateManagedConfig(policies);67
assert.match(text, /^# CH-J Server Manager/);68
assert.match(text, /\nDenyUsers alice\nDenyUsers bob@::\/0\nMatch User bob\n\tPasswordAuthentication no\n\tKbdInteractiveAuthentication no\nMatch all\n$/);69
assert.doesNotMatch(text, /carol/, "inherit-only policies are not written");70
const parsed = policy.parseManagedConfig(text);71
assert.equal(parsed.consistent, true);72
assert.deepEqual([...parsed.policies.keys()], ["alice", "bob"]);73
assert.equal(policy.parseManagedConfig(text.replace("DenyUsers alice\n", "")).consistent, false);74
assert.equal(policy.parseManagedConfig(`${text}PermitRootLogin yes\n`).consistent, false);75
assert.equal(policy.parseManagedConfig("").consistent, true);76
assert.equal(policy.generateManagedConfig(new Map([["x", policy.normalizePolicy({})]])), "");77
assert.throws(() => policy.normalizePolicy({ password: "maybe" }), { code: "USERS_SSH_POLICY_INVALID" });78
assert.throws(() => policy.normalizePolicy({ ipv4: "deny\nPermitRootLogin yes" }), { code: "USERS_SSH_POLICY_INVALID" });79
});81
test("sshd -T output is evaluated per user, address family and authentication method", () => {82
const dump = policy.parseEffective([83
"passwordauthentication yes", "kbdinteractiveauthentication no", "pubkeyauthentication yes", "permitrootlogin prohibit-password",84
"authenticationmethods any", "denyusers bob@0.0.0.0/0", "denyusers eve", "denyusers mallory@192.168.*", "addressfamily any",85
"listenaddress 0.0.0.0:22", "listenaddress [::]:22"86
].join("\n"));87
assert.equal(policy.evaluateAccess(dump, { username: "bob", address: "203.0.113.5" }).allowed, false);88
assert.equal(policy.evaluateAccess(dump, { username: "bob", address: "2001:db8::5" }).allowed, true);89
assert.equal(policy.evaluateAccess(dump, { username: "eve", address: "2001:db8::5" }).allowed, false);90
assert.equal(policy.evaluateAccess(dump, { username: "mallory", address: "192.168.1.4" }).allowed, false);91
assert.equal(policy.evaluateAccess(dump, { username: "mallory", address: "10.1.1.1" }).allowed, true);92
assert.equal(policy.evaluateAccess(dump, { username: "root", address: "10.1.1.1", method: "password" }).methodAllowed, false);93
assert.equal(policy.evaluateAccess(dump, { username: "root", address: "10.1.1.1", method: "publickey" }).methodAllowed, true);94
assert.deepEqual(policy.listenerFamilies(dump), { ipv4: true, ipv6: true });95
const restricted = policy.parseEffective("passwordauthentication yes\npubkeyauthentication yes\nauthenticationmethods publickey,password\nallowusers admin\nallowusers deploy@10.0.0.0/8\nallowgroups ops\nallowgroups openssh users\naddressfamily inet");96
assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "10.2.3.4", method: "publickey" }).methodAllowed, false, "publickey alone does not satisfy publickey,password");97
assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "10.2.3.4" }).allowed, true);98
assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "172.16.0.1" }).allowed, false);99
assert.equal(policy.evaluateAccess(restricted, { username: "admin", groups: ["users"], address: "10.2.3.4" }).allowed, false, "AllowGroups applies too");100
assert.equal(policy.evaluateAccess(restricted, { username: "admin", groups: ["openssh users"], address: "10.2.3.4" }).allowed, true, "group names with spaces are one entry");101
assert.equal(policy.evaluateAccess(policy.parseEffective("allowusers ops@*.example.com"), { username: "ops", address: "10.0.0.1" }).allowed, null, "hostnames cannot be evaluated without DNS");102
assert.deepEqual(policy.listenerFamilies(restricted), { ipv4: true, ipv6: false });103
assert.equal(policy.hostMatches("::/0", "203.0.113.5"), false);104
assert.equal(policy.hostMatches("0.0.0.0/0", "2001:db8::1"), false);105
assert.equal(policy.hostMatches("2001:db8::/32", "2001:db8::1"), true);106
assert.deepEqual(policy.verifyPolicyEffect({ ...policy.POLICY_DEFAULTS, password: "no", ipv4: "deny" }, "bob", dump, "IPv4"), ["IPv4: PasswordAuthentication is yes"]);107
});109
test("Core validation rejects names, paths and text that could reach a shell or passwd fields", () => {110
for (const value of ["bob;id", "$(id)", "`id`", "a b", "-rf", "Root", "1abc", "x".repeat(33), "", "bo\nb", "../x"]) {111
assert.throws(() => validateName(value), { code: "USERS_NAME_INVALID" }, value);112
}113
assert.equal(validateName("_svc-1"), "_svc-1");114
for (const value of ["/bin/sh;id", "bin/sh", "/bin/../tmp/x", "/bin/s h", "/bin/$(x)"]) assert.throws(() => validateShell(value), { code: "USERS_SHELL_INVALID" }, value);115
for (const value of ["/", "/etc", "/etc/x", "/usr/local/x", "/home/../etc", "/home/a b", "home/x", "/home/$(id)", "/root", "/var", "/proc/self"]) {116
assert.throws(() => validateHome(value), { code: "USERS_HOME_INVALID" }, value);117
}118
assert.equal(validateHome("/home/novak/"), "/home/novak");119
assert.equal(validateHome("/var/www/site"), "/var/www/site");120
for (const value of ["a:b", "line\nbreak", "x".repeat(101), "back\\slash"]) assert.throws(() => validateComment(value), { code: "USERS_COMMENT_INVALID" });121
assert.equal(validateComment("Jiří Novák, IT"), "Jiří Novák, IT");122
});124
test("remote helper script prints no password hashes and keeps secrets out of argv", () => {125
const script = fs.readFileSync(path.join(__dirname, "../src/main/users/remoteUsers.sh"), "utf8");126
assert.match(script, /Password hashes never leave the server/);127
assert.doesNotMatch(script, /getent shadow[^\n]*\$2[^\n]*print \$2/);128
assert.match(script, /printf '%s:%s\\n' "\$1" "\$secret" \| chpasswd/);129
assert.match(script, /read_frame SECRET/);130
assert.doesNotMatch(script, /\beval\b/);131
const sections = splitSections("\x1eCHJ_SECTION=meta\nuid=0\n\x1eCHJ_SECTION=passwd\nroot:x:0:0::/root:/bin/sh\n");132
assert.deepEqual(Object.keys(sections), ["meta", "passwd"]);133
});135
class FakeChannel extends EventEmitter {136
constructor() { super(); this.stderr = new EventEmitter(); this.writes = []; this.ended = false; this.closed = false; }137
write(data) { this.writes.push(String(data)); this.emit("input", String(data)); }138
end() { if (this.ended) return; this.ended = true; this.emit("input-end"); }139
close() { if (this.closed) return; this.closed = true; setImmediate(() => this.emit("close", null)); }140
out(text) { this.emit("data", Buffer.from(text)); }141
err(text) { this.stderr.emit("data", Buffer.from(text)); }142
exit(code) { if (!this.closed) { this.closed = true; setImmediate(() => this.emit("close", code)); } }143
}145
function managerWith(username, scenario) {146
const manager = new SessionManager({ profileService: {}, latencyMonitor: { start: () => () => {} } });147
const commands = [];148
const channels = [];149
const client = {150
exec(command, callback) {151
commands.push(command);152
const channel = new FakeChannel();153
channels.push(channel);154
callback(null, channel);155
setImmediate(() => {156
if (command.includes("sudoNonInteractive")) {157
channel.out(`user=${username}\nuid=${username === "root" ? 0 : 1000}\ngroups=${username},sudo\nclient=198.51.100.7\nsudoInstalled=1\nsudoNonInteractive=1\nsudoMessage=sudo: a password is required\n`);158
channel.exit(0);159
} else scenario(channel, command);160
});161
}162
};163
manager.sessions.set("s1", { sessionId: "s1", profileId: "p1", host: "server.test", username, state: "connected", stream: {}, client, decoder: { end: () => "" } });164
return { manager, executor: new PrivilegedExecutor({ sessionManager: manager }), commands, channels };165
}167
test("privileged executor sends the sudo password only after sudo prompts and frames only after READY", async () => {168
const { executor, commands, channels } = managerWith("admin", (channel) => {169
channel.once("input", (password) => {170
assert.equal(password, "s3cret\n");171
channel.once("input", (frame) => { channel.out(`got ${frame}`); channel.exit(0); });172
channel.out("\x1eCHJ_READY\n");173
});174
channel.err(SUDO_PROMPT);175
});176
const result = await executor.run("s1", { script: "echo fixed", args: ["inventory", "a'b"], frames: [{ tag: "SECRET", data: "account-pass" }], sudoPassword: "s3cret" });177
assert.match(commands[1], /^LC_ALL=C sudo -S -p '\[chj-sudo-auth\]' -- sh -c 'echo fixed' chj 'inventory' 'a'"'"'b'$/);178
assert.ok(commands.every((command) => !command.includes("s3cret") && !command.includes("account-pass")), "secrets never appear in argv");179
assert.equal(channels[1].writes[1], `CHJSECRET:${Buffer.from("account-pass").toString("base64")}\n`);180
assert.match(result.stdout, /got CHJSECRET:/);181
assert.equal((await executor.probe("s1")).mode, "sudo-password");182
assert.equal((await executor.probe("s1")).verified, true);183
});185
test("privileged executor reports missing, wrong and insufficient sudo authorization without hanging", async () => {186
let scenario = (channel) => { channel.on("input-end", () => channel.exit(1)); channel.err(SUDO_PROMPT); };187
const { executor, channels } = managerWith("admin", (channel, command) => scenario(channel, command));188
await assert.rejects(() => executor.run("s1", { script: "x" }), { code: "SUDO_PASSWORD_REQUIRED" });189
assert.equal(channels[1].writes.length, 0, "no input without a password");190
scenario = (channel) => {191
channel.once("input", () => setImmediate(() => channel.err(`Sorry, try again.\n${SUDO_PROMPT}`)));192
channel.on("input-end", () => channel.exit(1));193
channel.err(SUDO_PROMPT);194
};195
await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "wrong", frames: [{ tag: "DATA", data: "payload" }] }), { code: "SUDO_PASSWORD_INCORRECT" });196
assert.deepEqual(channels[2].writes, ["wrong\n"], "the frame is never sent to a sudo password prompt");197
scenario = (channel) => { channel.err("sudo: sorry, you must have a tty to run sudo\n"); channel.exit(1); };198
await assert.rejects(() => executor.run("s1", { script: "x" }), { code: "SUDO_TTY_REQUIRED" });199
scenario = (channel) => {200
channel.once("input", () => { channel.err("admin is not in the sudoers file.\n"); channel.exit(1); });201
channel.err(SUDO_PROMPT);202
};203
await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "right" }), { code: "SUDO_NOT_PERMITTED" });204
const before = channels.length;205
await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "right" }), { code: "SUDO_NOT_PERMITTED" });206
assert.equal(channels.length, before, "a verified lack of sudo is not retried or prompted again");207
});209
test("privileged executor maps remote error markers and survives disconnects", async () => {210
let scenario = (channel) => { channel.out("\x1eCHJ_READY\n\x1eCHJ_ERR=PROTECTED_USER\n"); channel.err("The root account is protected.\n"); channel.exit(3); };211
const { manager, executor, commands } = managerWith("root", (channel, command) => scenario(channel, command));212
await assert.rejects(() => executor.run("s1", { script: "x", errorPrefix: "USERS_" }), { code: "USERS_PROTECTED_USER", message: "The root account is protected." });213
assert.ok(commands[1].startsWith("LC_ALL=C sh -c "), "root sessions do not use sudo");214
scenario = (channel) => { channel.out("\x1eCHJ_READY\n\x1eCHJ_STAGED\n"); };215
const pending = executor.run("s1", { script: "x", interact: async (io) => { await io.waitFor(/\x1eCHJ_STAGED\n/); await io.waitFor(/never/, 60000); } });216
setTimeout(() => manager._finalize(manager.sessions.get("s1"), "test"), 20);217
await assert.rejects(() => pending, { code: "SESSION_NOT_CONNECTED" });218
});220
test("last working administrator and protected accounts cannot be removed", () => {221
const service = new UserAdminService({ sessionManager: {}, privilegedExecutor: {} });222
const inventory = (mode) => ({223
privilege: { mode },224
users: [225
{ username: "root", uid: 0, sudo: "full", loginShell: true, expired: false },226
{ username: "alice", uid: 1000, sudo: "full", loginShell: true, expired: false },227
{ username: "bob", uid: 1001, sudo: "none", loginShell: true, expired: false },228
{ username: "carol", uid: 1002, sudo: "full", loginShell: true, expired: true }229
]230
});231
assert.throws(() => service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "delete", {}), { code: "USERS_LAST_ADMIN" });232
assert.throws(() => service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "shell", { shell: "/usr/sbin/nologin" }), { code: "USERS_LAST_ADMIN" });233
service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "shell", { shell: "/bin/bash" });234
service._assertNotLastAdmin(inventory("sudo-password"), { username: "bob" }, "delete", {});235
service._assertNotLastAdmin(inventory("root"), { username: "alice" }, "delete", {});236
for (const protection of ["root", "system", "session"]) assert.throws(() => service._assertMutable({ protection }, "delete"), { code: "USERS_PROTECTED_USER" });237
service._assertMutable({ protection: null }, "delete");238
});240
test("users plugin UI renders server data as text and asks for sudo only on demand", () => {241
const root = path.join(__dirname, "../src/main/firstPartyPlugins/chj.users/0.0.1");242
const script = fs.readFileSync(path.join(root, "ui/index.js"), "utf8");243
const html = fs.readFileSync(path.join(root, "ui/index.html"), "utf8");244
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));245
assert.doesNotMatch(script, /\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML\(|document\.write\(/);246
assert.doesNotMatch(html, /id="sudoPassword"[^>]*class="toolbar/);247
assert.match(html, /<dialog id="sudoDialog"/);248
assert.match(script, /SUDO_PASSWORD_REQUIRED/);249
assert.deepEqual(manifest.permissions, ["session.read", "users.read", "users.manage", "keys.generate"]);250
assert.equal(manifest.pluginApi, "^1.3.0");251
});253
test("root SSH policy: PermitRootLogin only for root, compatible managed format and alias handling", async () => {254
const legacy = policy.generateManagedConfig(new Map([["bob", policy.normalizePolicy({ password: "no" })]]));255
assert.doesNotMatch(legacy, /permitRootLogin/, "files written before the option stay byte-identical");256
assert.equal(policy.parseManagedConfig(legacy).consistent, true);257
const withRoot = policy.generateManagedConfig(new Map([["root", policy.normalizePolicy({ permitRootLogin: "prohibit-password", ipv6: "deny" }, "root")]]));258
assert.match(withRoot, /# chj-policy root access=allow password=inherit pubkey=inherit ipv4=allow ipv6=deny permitRootLogin=prohibit-password/);259
assert.match(withRoot, /DenyUsers root@::\/0\nMatch User root\n\tPermitRootLogin prohibit-password\nMatch all\n$/);260
assert.equal(policy.parseManagedConfig(withRoot).policies.get("root").permitRootLogin, "prohibit-password");261
assert.throws(() => policy.normalizePolicy({ permitRootLogin: "yes" }, "bob"), { code: "USERS_SSH_POLICY_INVALID" });262
assert.throws(() => policy.normalizePolicy({ permitRootLogin: "maybe" }, "root"), { code: "USERS_SSH_POLICY_INVALID" });263
const dump = policy.parseEffective("permitrootlogin without-password\npasswordauthentication yes\npubkeyauthentication yes");264
assert.deepEqual(policy.verifyPolicyEffect({ ...policy.POLICY_DEFAULTS, permitRootLogin: "prohibit-password" }, "root", dump, "IPv4"), []);265
const evaluation = policy.evaluateAccess(dump, { username: "root", address: "192.0.2.1" });266
assert.equal(evaluation.methods.password, false); assert.equal(evaluation.methods.publickey, true);267
assert.equal(policy.evaluateAccess(policy.parseEffective("permitrootlogin no\npubkeyauthentication yes"), { username: "root", address: "192.0.2.1" }).allowed, false);268
const service = new UserAdminService({ sessionManager: {}, privilegedExecutor: { probe: async () => { throw new Error("must not run"); } } });269
await assert.rejects(() => service.sshPolicy("s", { username: "root", policy: { permitRootLogin: "yes" } }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" });270
await assert.rejects(() => service.sshPolicy("s", { username: "root", policy: { password: "yes" } }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" });271
});SHA-256
2551cf481a74ec984bfd264d6d7abcf09eb22fcad3228da676877ede314166c1CH-J Proprietary Software License 1.14 · Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
Archive SHA-256
cf4455b698f0f28e456a37b1834976a0194d8486acb7101052d59a838745cca3