공개된 소스
소스 ZIP 다운로드
CH-J Server Manager
특정 애플리케이션 릴리스의 디렉터리와 파일을 살펴보세요.
CH-J / Server Manager
377 개 파일
core-20261019T111616Z-160a00bfcddc
추가됨: · 빌드됨:
1
"use strict";3
const { SessionError, shellQuote } = require("./sessionManager");5
// Shared Core service for bounded privileged remote operations over an existing SSH session.6
// Callers pass a fixed script plus validated arguments; there is no API for arbitrary commands.7
// The sudo password is written to stdin only after sudo prints our prompt marker and is never8
// stored, logged or placed in argv. Payloads ("frames") are sent only after the script's READY marker.9
const SUDO_PROMPT = "[chj-sudo-auth]";10
const READY_MARKER = "\x1eCHJ_READY\n";11
const ERROR_MARKER = /\x1eCHJ_ERR=([A-Z0-9_]{2,64})\r?\n/;12
const MAX_PASSWORD_LENGTH = 1024;13
// Opt-in, memory-only reuse of a verified sudo password for one live SSH session (sudo itself14
// cannot cache across separate exec channels). Never persisted; dropped on disconnect, on a15
// rejected password and after the idle period.16
const SUDO_CACHE_IDLE_MS = 10 * 60 * 1000;17
const sudoCache = new WeakMap();18
const PROBE_SCRIPT = String.raw`LC_ALL=C; export LC_ALL19
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH20
printf 'user=%s\nuid=%s\ngroups=%s\nclient=%s\n' "$(id -un)" "$(id -u)" "$(id -Gn 2>/dev/null | tr ' ' ',')" "${"$"}{SSH_CLIENT%% *}"21
if command -v sudo >/dev/null 2>&1; then22
out=$(sudo -n -p '' true 2>&1 </dev/null); code=$?23
printf 'sudoInstalled=1\nsudoNonInteractive=%s\nsudoMessage=%s\n' "$code" "$(printf '%s' "$out" | head -n 1 | tr -d '\r\n' | cut -c1-300)"24
else25
printf 'sudoInstalled=0\n'26
fi`;28
function normalizePassword(value) {29
if (value === undefined || value === null || value === "") return "";30
const password = String(value);31
if (password.length > MAX_PASSWORD_LENGTH || /[\0\r\n]/.test(password)) throw new SessionError("SUDO_PASSWORD_INVALID", "Invalid sudo password.");32
return password;33
}35
function countOccurrences(text, token) {36
let count = 0;37
for (let index = text.indexOf(token); index >= 0; index = text.indexOf(token, index + token.length)) count += 1;38
return count;39
}41
function cleanRemoteMessage(text, limit = 600) {42
return String(text || "")43
.split(SUDO_PROMPT).join("")44
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ")45
.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)46
.slice(-6).join(" | ")47
.slice(0, limit);48
}50
function classifySudoFailure(stderr, code, state) {51
const text = String(stderr || "");52
if (state.authFailure) return state.authFailure;53
if (/incorrect password attempt|Sorry, try again/i.test(text)) return "SUDO_PASSWORD_INCORRECT";54
if (/is not in the sudoers file|is not allowed to run sudo|may not run sudo|not allowed to execute|is not allowed to run/i.test(text)) return "SUDO_NOT_PERMITTED";55
if (/must have a tty|a terminal is required|no tty present/i.test(text)) return "SUDO_TTY_REQUIRED";56
if (/a password is required|no password was provided/i.test(text)) return "SUDO_PASSWORD_REQUIRED";57
if (/account.*(locked|expired)|authentication (failure|token)|maximum number of tries/i.test(text)) return "SUDO_AUTH_FAILED";58
if (code === 127 || /sudo: (command )?not found/i.test(text)) return "SUDO_UNAVAILABLE";59
return "SUDO_FAILED";60
}62
const SUDO_MESSAGES = Object.freeze({63
SUDO_PASSWORD_REQUIRED: "This operation requires the sudo password of the SSH account.",64
SUDO_PASSWORD_INCORRECT: "The sudo password was rejected.",65
SUDO_NOT_PERMITTED: "The SSH account is not permitted to run this operation with sudo.",66
SUDO_TTY_REQUIRED: "The sudo policy requires a terminal (requiretty); CH-J does not bypass it.",67
SUDO_AUTH_FAILED: "sudo authentication failed or the account is locked.",68
SUDO_UNAVAILABLE: "sudo is not installed and the SSH account is not root.",69
SUDO_FAILED: "sudo did not start the operation."70
});72
function parseKeyValues(text) {73
const values = {};74
for (const line of String(text || "").split(/\r?\n/)) {75
const index = line.indexOf("=");76
if (index > 0) values[line.slice(0, index)] = line.slice(index + 1);77
}78
return values;79
}81
function publicPrivilege(privilege) {82
return {83
mode: privilege.mode,84
verified: privilege.verified === true,85
username: privilege.username,86
sudoInstalled: privilege.sudoInstalled === true,87
groups: [...(privilege.groups || [])],88
clientAddress: privilege.clientAddress || "",89
message: privilege.message || "",90
checkedAt: privilege.checkedAt91
};92
}94
class PrivilegedExecutor {95
constructor(options) {96
this.sessionManager = options.sessionManager;97
this.logger = options.logger;98
}100
// Detects what the SSH account can do without prompting: root, sudo without password,101
// sudo with password (not yet verified), or no sudo at all. Cached per live session.102
async probe(sessionId, { refresh = false } = {}) {103
const record = this.sessionManager._requireConnected(sessionId);104
if (record.privilege && !refresh) return publicPrivilege(record.privilege);105
const result = await this.sessionManager._execStream(record, `sh -c ${shellQuote(PROBE_SCRIPT)}`, { timeoutMs: 15000 }).done;106
const values = parseKeyValues(result.stdout);107
const privilege = {108
username: values.user || record.username,109
sudoInstalled: values.sudoInstalled === "1",110
groups: String(values.groups || "").split(",").filter(Boolean).slice(0, 256),111
clientAddress: String(values.client || "").trim().slice(0, 64),112
message: cleanRemoteMessage(values.sudoMessage || "", 300),113
checkedAt: new Date().toISOString(),114
mode: "unknown",115
verified: false116
};117
if (values.uid === "0") Object.assign(privilege, { mode: "root", verified: true });118
else if (!privilege.sudoInstalled) Object.assign(privilege, { mode: "none", verified: true });119
else if (values.sudoNonInteractive === "0") Object.assign(privilege, { mode: "sudo-nopasswd", verified: true });120
else if (/password is required/i.test(values.sudoMessage || "")) privilege.mode = "sudo-password";121
else if (/not allowed|may not run sudo|not in the sudoers/i.test(values.sudoMessage || "")) Object.assign(privilege, { mode: "none", verified: true });122
record.privilege = privilege;123
return publicPrivilege(privilege);124
}126
// Runs a fixed script. With privileged=true it runs as root (root session) or via sudo -S.127
// Frames are { tag, data } pairs written as base64 lines after READY. interact(channel) may drive128
// a confirmation protocol; otherwise stdin is closed after the frames.129
async run(sessionId, options) {130
const record = this.sessionManager._requireConnected(sessionId);131
const privileged = options.privileged !== false;132
let password = normalizePassword(options.sudoPassword);133
const cached = !password && options.useCachedPassword === true ? this._cachedPassword(record) : "";134
if (cached) password = cached;135
const privilege = privileged ? await this.probe(sessionId) : null;136
if (privileged && privilege.mode === "none" && privilege.verified) {137
throw new SessionError(privilege.sudoInstalled ? "SUDO_NOT_PERMITTED" : "SUDO_UNAVAILABLE",138
privilege.sudoInstalled ? SUDO_MESSAGES.SUDO_NOT_PERMITTED : SUDO_MESSAGES.SUDO_UNAVAILABLE);139
}140
const useSudo = privileged && privilege.mode !== "root";141
const body = `sh -c ${shellQuote(options.script)} chj ${(options.args || []).map((value) => shellQuote(value)).join(" ")}`;142
const command = useSudo ? `LC_ALL=C sudo -S -p ${shellQuote(SUDO_PROMPT)} -- ${body}` : `LC_ALL=C ${body}`;143
const frames = (options.frames || []).map(({ tag, data }) => {144
if (!/^[A-Z]{2,16}$/.test(tag)) throw new SessionError("PROTOCOL_ERROR", "Invalid frame tag.");145
return `CHJ${tag}:${Buffer.from(data ?? "", typeof data === "string" ? "utf8" : undefined).toString("base64")}\n`;146
});147
const state = { answered: 0, started: false, authFailure: null, interaction: null };148
const listeners = new Set();149
const channel = this.sessionManager._execStream(record, command, {150
timeoutMs: options.timeoutMs || 60000,151
outputLimit: options.outputLimit || 4 * 1024 * 1024,152
onUpdate: (controller) => {153
if (useSudo && !state.started) {154
const prompts = countOccurrences(controller.stderr, SUDO_PROMPT);155
if (prompts > state.answered) {156
if (state.answered === 0 && password) {157
state.answered = 1;158
controller.write(`${password}\n`);159
} else {160
state.answered = prompts;161
state.authFailure = password ? "SUDO_PASSWORD_INCORRECT" : "SUDO_PASSWORD_REQUIRED";162
controller.end();163
controller.close();164
}165
}166
}167
if (!state.started && controller.stdout.includes(READY_MARKER)) {168
state.started = true;169
for (const frame of frames) controller.write(frame);170
if (options.interact) {171
// Any failure closes stdin; the remote script treats EOF as "roll back".172
state.interaction = Promise.resolve()173
.then(() => options.interact(createInteraction(controller, listeners)))174
.catch((error) => { controller.end(); throw error; });175
state.interaction.catch(() => {});176
} else controller.end();177
}178
for (const listener of listeners) listener();179
}180
});181
const result = await channel.done;182
if (!state.started) {183
const code = classifySudoFailure(result.stderr, result.code, state);184
this._rememberFailure(record, code);185
if (code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED") sudoCache.delete(record);186
this.logger?.warn("Privileged remote operation was not started.", { sessionId: record.sessionId, host: record.host, purpose: options.purpose, code });187
throw new SessionError(code, SUDO_MESSAGES[code] || SUDO_MESSAGES.SUDO_FAILED, { remoteMessage: cleanRemoteMessage(result.stderr) });188
}189
if (useSudo) {190
const mode = (password && state.answered) || record.privilege.mode === "sudo-password" ? "sudo-password" : "sudo-nopasswd";191
record.privilege = { ...record.privilege, verified: true, mode };192
if (password && state.answered && (options.rememberPassword === true || cached)) {193
sudoCache.set(record, { password, expiresAt: Date.now() + SUDO_CACHE_IDLE_MS });194
}195
}196
const stdout = result.stdout.slice(result.stdout.indexOf(READY_MARKER) + READY_MARKER.length);197
const marker = stdout.match(ERROR_MARKER);198
if (marker) {199
throw new SessionError(`${options.errorPrefix || "REMOTE_"}${marker[1]}`, cleanRemoteMessage(result.stderr) || marker[1], { remoteExitCode: result.code });200
}201
const interaction = state.interaction ? await state.interaction : undefined;202
return { code: result.code, stdout, stderr: cleanRemoteMessage(result.stderr), interaction, privileged };203
}205
hasCachedPassword(sessionId) {206
const record = this.sessionManager.sessions?.get(sessionId);207
return Boolean(record && this._cachedPassword(record));208
}210
forgetPassword(sessionId) {211
const record = this.sessionManager.sessions?.get(sessionId);212
if (record) sudoCache.delete(record);213
}215
_cachedPassword(record) {216
const entry = sudoCache.get(record);217
if (!entry) return "";218
if (record.finalized || record.state !== "connected" || entry.expiresAt <= Date.now()) {219
sudoCache.delete(record);220
return "";221
}222
entry.expiresAt = Date.now() + SUDO_CACHE_IDLE_MS;223
return entry.password;224
}226
_rememberFailure(record, code) {227
if (!record.privilege) return;228
if (code === "SUDO_NOT_PERMITTED") record.privilege = { ...record.privilege, mode: "none", verified: true };229
else if (code === "SUDO_UNAVAILABLE") record.privilege = { ...record.privilege, mode: "none", verified: true, sudoInstalled: false };230
else if ((code === "SUDO_PASSWORD_REQUIRED" || code === "SUDO_PASSWORD_INCORRECT") && record.privilege.mode !== "sudo-password") {231
record.privilege = { ...record.privilege, mode: "sudo-password" };232
}233
}234
}236
// Minimal line protocol over a running exec channel: wait for a marker, then answer on stdin.237
function createInteraction(controller, listeners) {238
let consumed = controller.stdout.indexOf(READY_MARKER) + READY_MARKER.length;239
const waiters = new Set();240
const settleAll = (error) => {241
for (const waiter of waiters) { clearTimeout(waiter.timer); waiter.reject(error); }242
waiters.clear();243
};244
const check = () => {245
for (const waiter of [...waiters]) {246
const text = controller.stdout.slice(consumed);247
const match = text.match(waiter.pattern);248
if (!match) continue;249
waiters.delete(waiter);250
clearTimeout(waiter.timer);251
consumed += match.index + match[0].length;252
waiter.resolve({ match, before: text.slice(0, match.index) });253
}254
};255
listeners.add(check);256
controller.done.then(() => { check(); settleAll(new SessionError("PROTOCOL_ERROR", "The remote operation ended unexpectedly.")); }, settleAll);257
return {258
waitFor(pattern, timeoutMs = 60000) {259
return new Promise((resolve, reject) => {260
const waiter = { pattern, resolve, reject };261
waiter.timer = setTimeout(() => { waiters.delete(waiter); reject(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote operation did not respond in time.")); }, timeoutMs);262
waiter.timer.unref?.();263
waiters.add(waiter);264
check();265
});266
},267
send(line) { controller.write(`${line}\n`); },268
end() { controller.end(); }269
};270
}272
module.exports = { PROBE_SCRIPT, PrivilegedExecutor, READY_MARKER, SUDO_CACHE_IDLE_MS, SUDO_MESSAGES, SUDO_PROMPT, classifySudoFailure, cleanRemoteMessage };SHA-256
7f767f711adc6b0b13900a70a5e642b7b804370608a4831872ef855aa4b3009fCH-J Proprietary Software License 1.14 · 소스는 CH-J Proprietary Software License 1.14에 따라 제공됩니다. 소스를 공개한다고 해서 라이선스 조항이 바뀌거나 추가 권리가 부여되지는 않습니다.
압축 파일 SHA-256
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a