mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 15:13:18 +08:00
322 lines
9.3 KiB
JavaScript
322 lines
9.3 KiB
JavaScript
/**
|
|
* Agent 的 SSH 执行层
|
|
*
|
|
* 与终端 socket 的区别:这里走 exec 通道而不是 PTY。
|
|
* PTY 里混杂 ANSI 转义、提示符、用户正在编辑的半行命令,喂给模型全是噪声,
|
|
* 且无法可靠判断命令何时结束、退出码是多少。exec 通道能拿到干净的
|
|
* stdout / stderr / exitCode。
|
|
*
|
|
* 连接复用:一个 turn 内模型可能连续执行十几条命令,每条都重连太慢。
|
|
* 按 hostId 池化,空闲超时后回收。
|
|
*/
|
|
|
|
import ssh2Module from 'ssh2'
|
|
import { getConnectionOptions, handleProxyAndJumpHostConnection } from '../socket/terminal.js'
|
|
import { HostListDB } from '../utils/db-class.js'
|
|
|
|
const { Client: SSHClient } = ssh2Module
|
|
const hostListDB = new HostListDB().getInstance()
|
|
|
|
// 空闲连接保留时长
|
|
const IDLE_TTL_MS = 5 * 60 * 1000
|
|
// 单条命令默认超时
|
|
export const DEFAULT_TIMEOUT_MS = 60 * 1000
|
|
export const MAX_TIMEOUT_MS = 30 * 60 * 1000
|
|
// 单条命令最多收集的输出字节数,防止 `cat 大文件` 打爆内存
|
|
const MAX_COLLECT_BYTES = 2 * 1024 * 1024
|
|
|
|
/** hostId -> { client, jumpClients, refCount, idleTimer, connecting } */
|
|
const pool = new Map()
|
|
|
|
function clearIdleTimer(entry) {
|
|
if (entry.idleTimer) {
|
|
clearTimeout(entry.idleTimer)
|
|
entry.idleTimer = null
|
|
}
|
|
}
|
|
|
|
function scheduleIdleClose(hostId) {
|
|
const entry = pool.get(hostId)
|
|
if (!entry) return
|
|
clearIdleTimer(entry)
|
|
entry.idleTimer = setTimeout(() => {
|
|
if (entry.refCount > 0) return
|
|
closeEntry(hostId, entry)
|
|
}, IDLE_TTL_MS)
|
|
}
|
|
|
|
function closeEntry(hostId, entry) {
|
|
pool.delete(hostId)
|
|
clearIdleTimer(entry)
|
|
try {
|
|
entry.client?.end()
|
|
} catch {
|
|
// 忽略关闭异常
|
|
}
|
|
entry.jumpClients?.forEach((client) => {
|
|
try {
|
|
client?.end()
|
|
} catch {
|
|
// 忽略
|
|
}
|
|
})
|
|
}
|
|
|
|
/** 建立一条新的 SSH 连接(含代理与跳板机) */
|
|
async function connect(hostId) {
|
|
const hostInfo = await hostListDB.findOneAsync({ _id: hostId })
|
|
if (!hostInfo) throw new Error(`未找到主机: ${ hostId }`)
|
|
|
|
const { authInfo } = await getConnectionOptions(hostId)
|
|
|
|
let jumpClients = []
|
|
const proxyResult = await handleProxyAndJumpHostConnection({
|
|
hostInfo,
|
|
targetConnectionOptions: authInfo,
|
|
socket: null,
|
|
logPrefix: 'AI Agent '
|
|
})
|
|
jumpClients = proxyResult?.jumpSshClients || []
|
|
|
|
const client = await new Promise((resolve, reject) => {
|
|
const sshClient = new SSHClient()
|
|
const onError = (err) => {
|
|
jumpClients.forEach((item) => item?.end())
|
|
reject(new Error(`SSH 连接失败: ${ err.message }`))
|
|
}
|
|
sshClient
|
|
.on('ready', () => resolve(sshClient))
|
|
.on('error', onError)
|
|
.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
|
finish([authInfo[hostInfo.authType]])
|
|
})
|
|
.connect({ tryKeyboard: true, ...authInfo })
|
|
})
|
|
|
|
// 连接被对端关闭时从池里摘掉,避免下次拿到死连接
|
|
client.on('close', () => {
|
|
const entry = pool.get(hostId)
|
|
if (entry?.client === client) closeEntry(hostId, entry)
|
|
})
|
|
|
|
return { client, jumpClients, hostInfo }
|
|
}
|
|
|
|
/**
|
|
* 取得一条可用连接。调用方必须在用完后调用 release()。
|
|
*/
|
|
async function acquire(hostId) {
|
|
let entry = pool.get(hostId)
|
|
|
|
if (entry?.connecting) {
|
|
await entry.connecting
|
|
entry = pool.get(hostId)
|
|
}
|
|
|
|
if (!entry || !entry.client) {
|
|
const placeholder = { client: null, jumpClients: [], refCount: 0, idleTimer: null, connecting: null }
|
|
placeholder.connecting = connect(hostId)
|
|
.then(({ client, jumpClients, hostInfo }) => {
|
|
placeholder.client = client
|
|
placeholder.jumpClients = jumpClients
|
|
placeholder.hostInfo = hostInfo
|
|
placeholder.connecting = null
|
|
return placeholder
|
|
})
|
|
.catch((error) => {
|
|
pool.delete(hostId)
|
|
throw error
|
|
})
|
|
pool.set(hostId, placeholder)
|
|
await placeholder.connecting
|
|
entry = pool.get(hostId)
|
|
}
|
|
|
|
if (!entry?.client) throw new Error(`SSH 连接不可用: ${ hostId }`)
|
|
|
|
entry.refCount += 1
|
|
clearIdleTimer(entry)
|
|
|
|
return {
|
|
client: entry.client,
|
|
hostInfo: entry.hostInfo,
|
|
release: () => {
|
|
entry.refCount = Math.max(0, entry.refCount - 1)
|
|
if (entry.refCount === 0) scheduleIdleClose(hostId)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 主动断开某台主机的 agent 连接。
|
|
*
|
|
* 必须尊重 refCount:连接是按 hostId 池化的,同一台主机可能正被另一个
|
|
* 会话用着。直接 close 会把别人正在跑的命令掐断。还有人在用时改为
|
|
* 重排空闲回收,等最后一个使用者释放后自然关闭。
|
|
*/
|
|
export function disconnect(hostId) {
|
|
const entry = pool.get(hostId)
|
|
if (!entry) return
|
|
if (entry.refCount > 0) {
|
|
scheduleIdleClose(hostId)
|
|
return
|
|
}
|
|
closeEntry(hostId, entry)
|
|
}
|
|
|
|
/** 断开全部 agent 连接(进程退出或用户结束会话时调用) */
|
|
export function disconnectAll() {
|
|
for (const [hostId, entry] of pool.entries()) closeEntry(hostId, entry)
|
|
}
|
|
|
|
/**
|
|
* 包装命令:注入非交互环境变量,可选切换工作目录。
|
|
*
|
|
* 审计日志同时记录原始命令与包装后的命令,避免"用户批准的是 A、
|
|
* 实际执行的是 B"这种说不清的情况。
|
|
*/
|
|
/**
|
|
* 单引号转义。
|
|
*
|
|
* ⚠️ 不要用 JSON.stringify 代替:它产出的是双引号字符串,而双引号里
|
|
* `$(...)`、反引号、`$VAR` 仍会被 shell 展开。单引号才是字面量,
|
|
* 内部的单引号用 '\'' 的方式断开拼接。
|
|
*/
|
|
export function shellQuote(value) {
|
|
return `'${ String(value).replace(/'/g, '\'\\\'\'') }'`
|
|
}
|
|
|
|
export function wrapCommand(command, { cwd, nonInteractive = true } = {}) {
|
|
const parts = []
|
|
if (nonInteractive) parts.push('export DEBIAN_FRONTEND=noninteractive')
|
|
if (cwd) parts.push(`cd ${ shellQuote(cwd) }`)
|
|
parts.push(command)
|
|
return parts.join(' && ')
|
|
}
|
|
|
|
/**
|
|
* 在指定主机上执行一条命令。
|
|
*
|
|
* @param {string} hostId
|
|
* @param {string} command
|
|
* @param {object} [options]
|
|
* @param {number} [options.timeoutMs]
|
|
* @param {string} [options.cwd]
|
|
* @param {AbortSignal} [options.signal] 用户点击停止时中断
|
|
* @returns {Promise<{stdout,stderr,exitCode,signal,timedOut,truncated,durationMs,executed}>}
|
|
*/
|
|
export async function execCommand(hostId, command, options = {}) {
|
|
const timeoutMs = Math.min(Math.max(options.timeoutMs || DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS)
|
|
const executed = wrapCommand(command, options)
|
|
const startedAt = Date.now()
|
|
|
|
const { client, release } = await acquire(hostId)
|
|
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let collected = 0
|
|
let truncated = false
|
|
let settled = false
|
|
let timedOut = false
|
|
let stream = null
|
|
let timer = null
|
|
let onAbort = null
|
|
|
|
const cleanup = () => {
|
|
if (timer) clearTimeout(timer)
|
|
if (onAbort && options.signal) options.signal.removeEventListener('abort', onAbort)
|
|
}
|
|
|
|
const finish = (result) => {
|
|
if (settled) return
|
|
settled = true
|
|
cleanup()
|
|
resolve({ ...result, stdout, stderr, truncated, timedOut, durationMs: Date.now() - startedAt, executed })
|
|
}
|
|
|
|
const fail = (error) => {
|
|
if (settled) return
|
|
settled = true
|
|
cleanup()
|
|
reject(error)
|
|
}
|
|
|
|
const append = (target, chunk) => {
|
|
if (collected >= MAX_COLLECT_BYTES) {
|
|
truncated = true
|
|
return target
|
|
}
|
|
const text = chunk.toString('utf8')
|
|
collected += Buffer.byteLength(text)
|
|
if (collected > MAX_COLLECT_BYTES) truncated = true
|
|
return target + text
|
|
}
|
|
|
|
const kill = () => {
|
|
try {
|
|
stream?.signal('KILL')
|
|
stream?.close()
|
|
} catch {
|
|
// 通道可能已关闭
|
|
}
|
|
}
|
|
|
|
timer = setTimeout(() => {
|
|
timedOut = true
|
|
kill()
|
|
finish({ exitCode: null, signal: 'TIMEOUT' })
|
|
}, timeoutMs)
|
|
|
|
if (options.signal) {
|
|
if (options.signal.aborted) return fail(new Error('已取消'))
|
|
onAbort = () => {
|
|
kill()
|
|
finish({ exitCode: null, signal: 'ABORTED' })
|
|
}
|
|
options.signal.addEventListener('abort', onAbort, { once: true })
|
|
}
|
|
|
|
client.exec(executed, (err, execStream) => {
|
|
if (err) return fail(new Error(`命令执行失败: ${ err.message }`))
|
|
stream = execStream
|
|
|
|
stream
|
|
.on('close', (code, signalName) => {
|
|
finish({ exitCode: typeof code === 'number' ? code : null, signal: signalName || null })
|
|
})
|
|
.on('data', (chunk) => {
|
|
stdout = append(stdout, chunk)
|
|
})
|
|
.on('error', (streamErr) => fail(new Error(`命令流错误: ${ streamErr.message }`)))
|
|
|
|
stream.stderr.on('data', (chunk) => {
|
|
stderr = append(stderr, chunk)
|
|
})
|
|
})
|
|
})
|
|
} finally {
|
|
release()
|
|
}
|
|
}
|
|
|
|
/** 打开一个 SFTP 会话,回调结束后自动释放 */
|
|
export async function withSftp(hostId, handler) {
|
|
const { client, release } = await acquire(hostId)
|
|
let sftp = null
|
|
try {
|
|
sftp = await new Promise((resolve, reject) => {
|
|
client.sftp((err, session) => (err ? reject(new Error(`SFTP 打开失败: ${ err.message }`)) : resolve(session)))
|
|
})
|
|
return await handler(sftp)
|
|
} finally {
|
|
try {
|
|
sftp?.end()
|
|
} catch {
|
|
// 忽略
|
|
}
|
|
release()
|
|
}
|
|
}
|