417 lines
15 KiB
JavaScript
417 lines
15 KiB
JavaScript
/* global console, module, require, clearTimeout, setTimeout */
|
||
/* eslint-disable no-control-regex */
|
||
|
||
const { createGatewayClient } = require("./gateway");
|
||
const { validateServerForConnect } = require("./serverValidation");
|
||
const { resolveSocketDomainHint } = require("./socketDomain");
|
||
|
||
/**
|
||
* 认证参数与 terminal 页保持一致,避免多处行为分叉。
|
||
*/
|
||
function normalizeAuthType(value) {
|
||
const authType = String(value || "").trim();
|
||
if (authType === "privateKey" || authType === "certificate") return authType;
|
||
return "password";
|
||
}
|
||
|
||
function resolveCredential(server, prefix) {
|
||
const source = server && typeof server === "object" ? server : {};
|
||
const fieldPrefix = String(prefix || "");
|
||
const authType = normalizeAuthType(
|
||
fieldPrefix ? source.jumpHost && source.jumpHost.authType : source.authType
|
||
);
|
||
const passwordKey = fieldPrefix ? `${fieldPrefix}Password` : "password";
|
||
const privateKeyKey = fieldPrefix ? `${fieldPrefix}PrivateKey` : "privateKey";
|
||
const passphraseKey = fieldPrefix ? `${fieldPrefix}Passphrase` : "passphrase";
|
||
const certificateKey = fieldPrefix ? `${fieldPrefix}Certificate` : "certificate";
|
||
if (authType === "privateKey") {
|
||
return {
|
||
type: "privateKey",
|
||
privateKey: String(source[privateKeyKey] || ""),
|
||
passphrase: String(source[passphraseKey] || "")
|
||
};
|
||
}
|
||
if (authType === "certificate") {
|
||
return {
|
||
type: "certificate",
|
||
privateKey: String(source[privateKeyKey] || ""),
|
||
passphrase: String(source[passphraseKey] || ""),
|
||
certificate: String(source[certificateKey] || "")
|
||
};
|
||
}
|
||
return {
|
||
type: "password",
|
||
password: String(source[passwordKey] || "")
|
||
};
|
||
}
|
||
|
||
function normalizeHomePath(input) {
|
||
const value = String(input || "").trim();
|
||
if (!value || value === "~") return "~";
|
||
if (!value.startsWith("~/")) return "~";
|
||
const body = value
|
||
.slice(2)
|
||
.split("/")
|
||
.map((part) => part.trim())
|
||
.filter((part) => part && part !== "." && part !== "..")
|
||
.join("/");
|
||
return body ? `~/${body}` : "~";
|
||
}
|
||
|
||
function quoteForShellSingle(value) {
|
||
return `'${String(value || "").replace(/'/g, `'"'"'`)}'`;
|
||
}
|
||
|
||
function buildListCommand(path, beginMarker, endMarker) {
|
||
const normalized = normalizeHomePath(path);
|
||
const relative = normalized === "~" ? "" : normalized.slice(2);
|
||
const relQuoted = quoteForShellSingle(relative);
|
||
const beginQuoted = quoteForShellSingle(beginMarker);
|
||
const endQuoted = quoteForShellSingle(endMarker);
|
||
// 外层仅执行 `sh -lc '...'`,避免依赖远端默认 shell 对 `A=B cmd` 前缀赋值语法的支持。
|
||
// 目录列举优先 command ls -1tp(按修改时间从新到旧,且绕过 alias),再回退 find。
|
||
const script = [
|
||
`__RC_BEGIN=${beginQuoted}`,
|
||
`__RC_END=${endQuoted}`,
|
||
`__RC_REL=${relQuoted}`,
|
||
'if [ -z "$__RC_REL" ]; then __RC_DIR="$HOME"; else __RC_DIR="$HOME/$__RC_REL"; fi',
|
||
'printf "%s\\n" "$__RC_BEGIN"',
|
||
'if ! cd "$__RC_DIR" 2>/dev/null; then printf "__RCERR__CD__\\n"; printf "%s\\n" "$__RC_END"; exit 0; fi',
|
||
"if command -v ls >/dev/null 2>&1; then command ls -1tp 2>/dev/null | sed -n '/\\/$/s#/$##p' | sed '/^\\./d'; elif command -v find >/dev/null 2>&1; then find . -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null | sed 's#^\\./##'; fi",
|
||
'printf "%s\\n" "$__RC_END"'
|
||
].join("; ");
|
||
const scriptQuoted = quoteForShellSingle(script);
|
||
return `sh -lc ${scriptQuoted}\n`;
|
||
}
|
||
|
||
function createStdoutCollector(beginMarker, endMarker) {
|
||
let buffer = "";
|
||
let done = false;
|
||
let collecting = false;
|
||
const extractedLines = [];
|
||
let beginSignalSeen = false;
|
||
|
||
function sanitizeChunk(input) {
|
||
return sanitizeTerminalChunk(input);
|
||
}
|
||
|
||
function consume(rawChunk) {
|
||
if (done) return;
|
||
buffer += sanitizeChunk(rawChunk);
|
||
if (!beginSignalSeen && buffer.includes(beginMarker)) {
|
||
beginSignalSeen = true;
|
||
}
|
||
// 必须按“整行”识别 begin/end 标记,避免命令回显折行时把标记误判成目录项。
|
||
while (!done) {
|
||
const newlineAt = buffer.indexOf("\n");
|
||
if (newlineAt < 0) break;
|
||
const rawLine = buffer.slice(0, newlineAt);
|
||
buffer = buffer.slice(newlineAt + 1);
|
||
const line = rawLine.replace(/\r/g, "");
|
||
const trimmed = line.trim();
|
||
if (!collecting) {
|
||
if (trimmed === beginMarker) {
|
||
collecting = true;
|
||
beginSignalSeen = true;
|
||
}
|
||
continue;
|
||
}
|
||
if (trimmed === endMarker) {
|
||
done = true;
|
||
break;
|
||
}
|
||
extractedLines.push(line);
|
||
}
|
||
}
|
||
|
||
function isDone() {
|
||
return done;
|
||
}
|
||
|
||
function hasBeginMarker() {
|
||
return collecting || done;
|
||
}
|
||
|
||
function hasBeginSignal() {
|
||
return beginSignalSeen || collecting || done;
|
||
}
|
||
|
||
function getLines() {
|
||
return extractedLines.map((line) => line.trim()).filter((line) => !!line);
|
||
}
|
||
|
||
return { consume, isDone, hasBeginMarker, hasBeginSignal, getLines };
|
||
}
|
||
|
||
function sanitizeTerminalChunk(input) {
|
||
return String(input || "")
|
||
.replace(/\r/g, "")
|
||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
|
||
.replace(/\u001bP[\s\S]*?\u001b\\/g, "")
|
||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
|
||
.replace(/\u001b[@-~]/g, "");
|
||
}
|
||
|
||
function toErrorMessage(error, fallback) {
|
||
if (!error) return fallback;
|
||
if (typeof error === "string") return error;
|
||
if (typeof error.message === "string" && error.message.trim()) return error.message.trim();
|
||
if (typeof error.errMsg === "string" && error.errMsg.trim()) return error.errMsg.trim();
|
||
return fallback;
|
||
}
|
||
|
||
function isLikelyErrorLine(line) {
|
||
const text = String(line || "");
|
||
if (!text) return false;
|
||
return /^(?:ls|find|sh):|not found|no such file|permission denied/i.test(text);
|
||
}
|
||
|
||
function isInternalMarkerLine(line) {
|
||
const text = String(line || "");
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return false;
|
||
if (/__RC_/i.test(trimmed)) return true;
|
||
if (/~\/__RC_/i.test(trimmed)) return true;
|
||
if (/(?:^|\s)gavin\b.*\b(?:%|#|\$)\b.*__RC_/i.test(trimmed)) return true;
|
||
const normalized = trimmed
|
||
.replace(/[\u001b\u009b][[()\]#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "")
|
||
.replace(/[^a-zA-Z0-9_]/g, "")
|
||
.toUpperCase();
|
||
if (!normalized) return false;
|
||
if (normalized.includes("RCDIRBEGIN")) return true;
|
||
if (normalized.includes("RCDIREND")) return true;
|
||
if (normalized.includes("RCBEGIN")) return true;
|
||
if (normalized.includes("RCEND")) return true;
|
||
if (normalized.includes("RCREL")) return true;
|
||
if (normalized.includes("RCERR")) return true;
|
||
if (normalized.includes("LIST_REMOTEDIRECTORIES_DONE".replace(/[^A-Z0-9_]/g, ""))) return true;
|
||
if (!text) return false;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 通过一次短连接从远端目录读取子目录列表。
|
||
* 返回值仅包含“目录名”,不包含完整路径。
|
||
*/
|
||
function listRemoteDirectories(options) {
|
||
const params = options && typeof options === "object" ? options : {};
|
||
const server = params.server || {};
|
||
const opsConfig = params.opsConfig || {};
|
||
const targetPath = normalizeHomePath(params.path || "~");
|
||
const requestId = `dir_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const requestStartedAt = Date.now();
|
||
const connectTimeoutMs = Number(opsConfig.gatewayConnectTimeoutMs);
|
||
const timeoutMs = Number(params.timeoutMs);
|
||
const maxWaitMs =
|
||
Number.isFinite(timeoutMs) && timeoutMs >= 1000
|
||
? timeoutMs
|
||
: Number.isFinite(connectTimeoutMs) && connectTimeoutMs >= 1000
|
||
? connectTimeoutMs + 10000
|
||
: 22000;
|
||
|
||
const beginMarker = `__RC_DIR_BEGIN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}__`;
|
||
const endMarker = `__RC_DIR_END_${Date.now()}_${Math.random().toString(36).slice(2, 8)}__`;
|
||
const collector = createStdoutCollector(beginMarker, endMarker);
|
||
const command = buildListCommand(targetPath, beginMarker, endMarker);
|
||
|
||
console.info(
|
||
`[目录请求] 开始 id=${requestId} host=${String(server.host || "").trim() || "-"} ` +
|
||
`port=${Number(server.port) || 22} user=${String(server.username || "").trim() || "-"} path=${targetPath}`
|
||
);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let settled = false;
|
||
let connected = false;
|
||
let timer = null;
|
||
let commandAttempts = 0;
|
||
// 目录读取改为单次发送,避免重试导致重复 ls 拉长耗时。
|
||
const maxCommandAttempts = 1;
|
||
let sawFirstOutput = false;
|
||
const diagnosticLines = [];
|
||
|
||
function rememberDiagnostics(rawChunk) {
|
||
const lines = sanitizeTerminalChunk(rawChunk)
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter((line) => !!line)
|
||
.filter((line) => !isInternalMarkerLine(line));
|
||
lines.forEach((line) => {
|
||
if (diagnosticLines.includes(line)) return;
|
||
diagnosticLines.push(line);
|
||
});
|
||
if (diagnosticLines.length > 6) {
|
||
diagnosticLines.splice(0, diagnosticLines.length - 6);
|
||
}
|
||
}
|
||
|
||
function sendCommandAttempt(trigger) {
|
||
if (settled) return;
|
||
if (!connected) return;
|
||
if (collector.isDone()) return;
|
||
if (commandAttempts >= maxCommandAttempts) return;
|
||
commandAttempts += 1;
|
||
console.info(
|
||
`[目录请求] 发送命令 id=${requestId} attempt=${commandAttempts}/${maxCommandAttempts} ` +
|
||
`trigger=${String(trigger || "unknown")} elapsed=${Date.now() - requestStartedAt}ms`
|
||
);
|
||
try {
|
||
client.sendStdin(command);
|
||
} catch {
|
||
finishReject(new Error("目录命令发送失败"));
|
||
return;
|
||
}
|
||
}
|
||
|
||
const client = createGatewayClient({
|
||
gatewayUrl: opsConfig.gatewayUrl,
|
||
gatewayToken: opsConfig.gatewayToken,
|
||
connectTimeoutMs: opsConfig.gatewayConnectTimeoutMs,
|
||
onFrame: (frame) => {
|
||
if (settled) return;
|
||
if (
|
||
frame.type === "control" &&
|
||
frame.payload?.action === "connected" &&
|
||
!frame.payload?.fingerprint
|
||
) {
|
||
connected = true;
|
||
console.info(`[目录请求] 已连接 id=${requestId} elapsed=${Date.now() - requestStartedAt}ms`);
|
||
// connected 事件里立即首发,避免定时器抖动导致额外等待。
|
||
sendCommandAttempt("connected");
|
||
return;
|
||
}
|
||
if (frame.type === "control" && frame.payload?.action === "connected" && frame.payload?.fingerprint) {
|
||
return;
|
||
}
|
||
if (frame.type === "stdout" || frame.type === "stderr") {
|
||
const chunkText = frame.payload?.data || "";
|
||
if (!collector.hasBeginSignal()) {
|
||
rememberDiagnostics(chunkText);
|
||
}
|
||
if (!sawFirstOutput && String(chunkText || "").trim()) {
|
||
sawFirstOutput = true;
|
||
console.info(`[目录请求] 首包输出 id=${requestId} elapsed=${Date.now() - requestStartedAt}ms`);
|
||
// 若连接后尚未发出目录命令,首包输出到达时立即触发一次发送。
|
||
if (connected && commandAttempts === 0 && !collector.isDone()) {
|
||
sendCommandAttempt("first_output");
|
||
}
|
||
}
|
||
const beforeDone = collector.isDone();
|
||
collector.consume(chunkText);
|
||
const afterDone = collector.isDone();
|
||
if (beforeDone || !afterDone) return;
|
||
if (!collector.isDone()) return;
|
||
const rawLines = collector.getLines();
|
||
const hasCdError = rawLines.some((line) => line.trim() === "__RCERR__CD__");
|
||
if (hasCdError) {
|
||
finishReject(new Error(`目录不存在或无权限:${targetPath}`));
|
||
return;
|
||
}
|
||
const names = rawLines
|
||
.map((line) => line.trim())
|
||
.filter((line) => line && line !== "." && line !== "..")
|
||
.filter((line) => !isInternalMarkerLine(line))
|
||
.filter((line) => !line.startsWith("."))
|
||
.filter((line) => !isLikelyErrorLine(line))
|
||
.filter((line, index, arr) => arr.indexOf(line) === index);
|
||
console.info(
|
||
`[目录请求] 完成 id=${requestId} path=${targetPath} count=${names.length} ` +
|
||
`attempts=${commandAttempts} elapsed=${Date.now() - requestStartedAt}ms`
|
||
);
|
||
finishResolve(names);
|
||
return;
|
||
}
|
||
if (frame.type === "error") {
|
||
finishReject(new Error(frame.payload?.message || "网关错误"));
|
||
return;
|
||
}
|
||
if (frame.type === "control" && frame.payload?.action === "disconnect") {
|
||
const reason = String(frame.payload?.reason || "").trim();
|
||
if (!collector.isDone()) {
|
||
finishReject(new Error(reason ? `连接已断开: ${reason}` : "连接已断开"));
|
||
}
|
||
}
|
||
},
|
||
onClose: () => {
|
||
if (settled || collector.isDone()) return;
|
||
finishReject(new Error(connected ? "连接中断" : "网关连接失败"));
|
||
},
|
||
onError: (error) => {
|
||
if (settled) return;
|
||
finishReject(new Error(toErrorMessage(error, "网关连接失败")));
|
||
}
|
||
});
|
||
|
||
function cleanup() {
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
timer = null;
|
||
}
|
||
try {
|
||
client.disconnect("list_remote_directories_done");
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function finishResolve(payload) {
|
||
if (settled) return;
|
||
settled = true;
|
||
cleanup();
|
||
resolve(payload);
|
||
}
|
||
|
||
function finishReject(error) {
|
||
if (settled) return;
|
||
settled = true;
|
||
console.warn(
|
||
`[目录请求] 失败 id=${requestId} path=${targetPath} attempts=${commandAttempts} ` +
|
||
`elapsed=${Date.now() - requestStartedAt}ms reason=${toErrorMessage(error, "unknown")}`
|
||
);
|
||
cleanup();
|
||
reject(error);
|
||
}
|
||
|
||
timer = setTimeout(() => {
|
||
const diagnostic = diagnosticLines.find((line) => !isLikelyErrorLine(line)) || diagnosticLines[0] || "";
|
||
if (diagnostic) {
|
||
finishReject(new Error(`目录读取超时,远端返回:${diagnostic}`));
|
||
return;
|
||
}
|
||
finishReject(new Error("目录读取超时,请稍后重试"));
|
||
}, maxWaitMs);
|
||
|
||
client
|
||
.connect({
|
||
host: server.jumpHost && server.jumpHost.enabled ? server.jumpHost.host : server.host,
|
||
port: server.jumpHost && server.jumpHost.enabled ? Number(server.jumpHost.port) || 22 : server.port,
|
||
username: server.jumpHost && server.jumpHost.enabled ? server.jumpHost.username : server.username,
|
||
credential:
|
||
server.jumpHost && server.jumpHost.enabled
|
||
? resolveCredential(server, "jump")
|
||
: resolveCredential(server),
|
||
...(server.jumpHost && server.jumpHost.enabled
|
||
? {
|
||
jumpHost: {
|
||
host: server.host,
|
||
port: server.port,
|
||
username: server.username,
|
||
credential: resolveCredential(server)
|
||
}
|
||
}
|
||
: {}),
|
||
cols: 80,
|
||
rows: 24
|
||
})
|
||
.catch((error) => {
|
||
finishReject(new Error(toErrorMessage(error, "网关连接失败")));
|
||
});
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
listRemoteDirectories,
|
||
normalizeHomePath,
|
||
validateServerForConnect,
|
||
resolveSocketDomainHint
|
||
};
|