Files
goodbuddy/src/main/ssh/ssh-transport.ts
T
mesalogo 010aafb6f4 perf(startup): load canvas, node-pty, ssh2, html-to-text and the OpenCode SDK on first use
Main module evaluation drops from ~420 ms to ~330 ms. Types stay as import type; the DeepSeek Harness chunks stay static because the protocol chunk shares code with Main startup paths.
2026-10-03 17:15:13 +08:00

553 lines
14 KiB
TypeScript

import { createHash, getCiphers } from 'node:crypto'
import type {
Client,
Algorithms,
CipherAlgorithm,
ConnectConfig,
ServerHostKeyAlgorithm
} from 'ssh2'
import { loadSsh2 } from './ssh2-module'
import type {
SshHostConnectionTestResult
} from '../../shared/ssh-host-contracts'
import type { ResolvedSshHost } from './ssh-host-store'
export const SSH_CONNECTION_TIMEOUT_MS = 10_000
export const SSH_KEEPALIVE_INTERVAL_MS = 5_000
export const SSH_KEEPALIVE_COUNT_MAX = 2
const PROBE_TIMEOUT_MS = 10_000
const MAX_PROBE_OUTPUT_BYTES = 16 * 1024
const MAX_HOST_KEY_BYTES = 32 * 1024
const PROBE_MARKER = 'GOODBUDDY_SSH_PROBE_V1'
const PROBE_COMMAND = [
`printf '${PROBE_MARKER}\\n'`,
"uname -s 2>/dev/null || printf 'unknown\\n'",
"uname -m 2>/dev/null || printf 'unknown\\n'",
"printf '%s\\n' \"${SHELL:-unknown}\"",
"printf '%s\\n' \"${HOME:-unknown}\""
].join('; ')
const strongAlgorithms: Omit<Algorithms, 'cipher'> = {
kex: [
'curve25519-sha256',
'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha256',
'diffie-hellman-group15-sha512',
'diffie-hellman-group16-sha512',
'diffie-hellman-group17-sha512',
'diffie-hellman-group18-sha512'
],
serverHostKey: [
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'rsa-sha2-512',
'rsa-sha2-256'
],
hmac: [
'hmac-sha2-512-etm@openssh.com',
'hmac-sha2-256-etm@openssh.com',
'hmac-sha2-512',
'hmac-sha2-256'
]
}
const strongCipherCandidates = [
{
sshName: 'chacha20-poly1305@openssh.com',
openSslName: 'chacha20'
},
{
sshName: 'aes256-gcm@openssh.com',
openSslName: 'aes-256-gcm'
},
{
sshName: 'aes128-gcm@openssh.com',
openSslName: 'aes-128-gcm'
},
{ sshName: 'aes256-ctr', openSslName: 'aes-256-ctr' },
{ sshName: 'aes192-ctr', openSslName: 'aes-192-ctr' },
{ sshName: 'aes128-ctr', openSslName: 'aes-128-ctr' }
] as const
export function selectStrongSshCiphers(
supportedOpenSslCiphers: readonly string[]
): CipherAlgorithm[] {
const supported = new Set(supportedOpenSslCiphers)
const selected = strongCipherCandidates
.filter((candidate) => supported.has(candidate.openSslName))
.map((candidate) => candidate.sshName)
if (selected.length === 0) {
throw new Error('当前运行环境不支持 GoodBuddy 要求的安全 SSH 加密算法')
}
return selected
}
export function createStrongSshAlgorithms(
supportedOpenSslCiphers: readonly string[]
): Algorithms {
return {
...strongAlgorithms,
cipher: selectStrongSshCiphers(supportedOpenSslCiphers)
}
}
export type SshConnectionTarget = {
hostname: string
port: number
username: string
}
export type SshHostKeyCandidate = {
algorithm: string
publicKeyBase64: string
fingerprintSha256: string
}
export interface SshTransport {
inspectHostKey(
target: SshConnectionTarget
): Promise<SshHostKeyCandidate>
testConnection(
host: ResolvedSshHost
): Promise<Omit<SshHostConnectionTestResult, 'hostId'>>
}
type ClientLike = Pick<Client, 'connect' | 'end' | 'destroy' | 'exec'> & {
once: Client['once']
on: Client['on']
}
type SshTransportDependencies = {
createClient: () => ClientLike
now: () => number
supportedOpenSslCiphers: () => readonly string[]
systemAgent: () => string | undefined
}
function readSshString(
value: Buffer,
offset: number
): { value: string; offset: number } {
if (offset + 4 > value.length) {
throw new Error('SSH 主机密钥格式无效')
}
const length = value.readUInt32BE(offset)
const start = offset + 4
const end = start + length
if (length <= 0 || end > value.length) {
throw new Error('SSH 主机密钥格式无效')
}
return {
value: value.subarray(start, end).toString('ascii'),
offset: end
}
}
export function describeSshHostKey(
publicKey: Buffer
): SshHostKeyCandidate {
if (
publicKey.byteLength === 0 ||
publicKey.byteLength > MAX_HOST_KEY_BYTES
) {
throw new Error('SSH 主机密钥大小无效')
}
const algorithm = readSshString(publicKey, 0).value
if (
![
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'ssh-rsa'
].includes(algorithm)
) {
throw new Error('SSH 主机密钥算法不受支持')
}
return {
algorithm,
publicKeyBase64: publicKey.toString('base64'),
fingerprintSha256: `SHA256:${createHash('sha256')
.update(publicKey)
.digest('base64')
.replace(/=+$/u, '')}`
}
}
export function defaultSystemAgent(): string | undefined {
const configured = process.env.SSH_AUTH_SOCK?.trim()
if (configured) {
return configured
}
return process.platform === 'win32'
? '\\\\.\\pipe\\openssh-ssh-agent'
: undefined
}
export function safeSshError(error: unknown): Error {
if (
error !== null &&
typeof error === 'object' &&
'level' in error &&
typeof error.level === 'string'
) {
if (error.level.includes('client-authentication')) {
return new Error('SSH 认证失败,请检查用户名和认证凭据')
}
if (error.level.includes('client-timeout')) {
return new Error('SSH 连接超时')
}
}
const code =
error !== null &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string'
? error.code
: ''
if (code === 'ENOTFOUND') {
return new Error('无法解析 SSH 主机地址')
}
if (code === 'ECONNREFUSED') {
return new Error('SSH 主机拒绝连接')
}
if (code === 'ETIMEDOUT') {
return new Error('SSH 连接超时')
}
return new Error('无法连接 SSH 主机')
}
function selectPinnedServerHostKeyAlgorithms(
algorithm: string
): ServerHostKeyAlgorithm[] {
switch (algorithm) {
case 'ssh-ed25519':
case 'ecdsa-sha2-nistp256':
case 'ecdsa-sha2-nistp384':
case 'ecdsa-sha2-nistp521':
return [algorithm]
case 'ssh-rsa':
return ['rsa-sha2-512', 'rsa-sha2-256']
default:
throw new Error('SSH 主机密钥算法不受支持')
}
}
export function buildAuthenticatedSshConnectConfig(
host: ResolvedSshHost,
algorithms: Algorithms,
systemAgent: string | undefined,
onHostKeyMismatch: () => void
): ConnectConfig {
if (!host.hostKey) {
throw new Error('请先验证并接受 SSH 主机密钥')
}
const publicKey = Buffer.from(
host.hostKey.publicKeyBase64,
'base64'
)
if (publicKey.byteLength === 0) {
throw new Error('SSH 主机密钥无效')
}
const pinnedAlgorithm = describeSshHostKey(publicKey).algorithm
const pinnedServerHostKeyAlgorithms =
selectPinnedServerHostKeyAlgorithms(pinnedAlgorithm)
const config: ConnectConfig = {
host: host.hostname,
port: host.port,
username: host.username,
readyTimeout: SSH_CONNECTION_TIMEOUT_MS,
keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
keepaliveCountMax: SSH_KEEPALIVE_COUNT_MAX,
algorithms: {
...algorithms,
serverHostKey: [...pinnedServerHostKeyAlgorithms]
},
agentForward: false,
hostVerifier: (key: Buffer) => {
const matches = key.equals(publicKey)
if (!matches) {
onHostKeyMismatch()
}
return matches
}
}
if (host.authentication === 'password') {
if (!host.password) {
throw new Error('SSH 主机尚未配置密码')
}
config.password = host.password
} else {
if (!systemAgent) {
throw new Error('当前系统未检测到可用的 SSH Agent')
}
config.agent = systemAgent
}
return config
}
function parseProbeOutput(
stdout: Buffer,
latencyMs: number
): Omit<SshHostConnectionTestResult, 'hostId'> {
const lines = stdout
.toString('utf8')
.replaceAll('\r\n', '\n')
.trimEnd()
.split('\n')
if (lines[0] !== PROBE_MARKER || lines.length < 5) {
throw new Error('SSH 远端系统探测返回了无效结果')
}
const platform =
lines[1] === 'Linux'
? 'linux'
: lines[1] === 'Darwin'
? 'darwin'
: /^MINGW|^MSYS|^CYGWIN|^Windows_NT$/u.test(lines[1] ?? '')
? 'win32'
: 'unknown'
const architecture =
/^(x86_64|amd64)$/iu.test(lines[2] ?? '')
? 'x64'
: /^(aarch64|arm64)$/iu.test(lines[2] ?? '')
? 'arm64'
: 'unknown'
return {
connected: true,
latencyMs,
platform,
architecture,
shell: (lines[3] ?? 'unknown').slice(0, 256),
homeDirectory: (lines[4] ?? 'unknown').slice(0, 4_096),
detail: `SSH 已连接,远端系统为 ${platform}/${architecture}`
}
}
export class Ssh2Transport implements SshTransport {
private readonly dependencies: SshTransportDependencies
private readonly algorithms: Algorithms
constructor(
dependencies: Partial<SshTransportDependencies> = {}
) {
this.dependencies = {
createClient: () => new (loadSsh2().Client)(),
now: Date.now,
supportedOpenSslCiphers: getCiphers,
systemAgent: defaultSystemAgent,
...dependencies
}
this.algorithms = createStrongSshAlgorithms(
this.dependencies.supportedOpenSslCiphers()
)
}
inspectHostKey(
target: SshConnectionTarget
): Promise<SshHostKeyCandidate> {
return new Promise((resolve, reject) => {
const client = this.dependencies.createClient()
let settled = false
const settle = (
outcome:
| { ok: true; value: SshHostKeyCandidate }
| { ok: false; error: Error }
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
client.destroy()
if (outcome.ok) {
resolve(outcome.value)
} else {
reject(outcome.error)
}
}
const timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 主机身份检查超时')
}),
SSH_CONNECTION_TIMEOUT_MS
)
client.once('error', (error) => {
settle({ ok: false, error: safeSshError(error) })
})
client.connect({
host: target.hostname,
port: target.port,
username: target.username,
readyTimeout: SSH_CONNECTION_TIMEOUT_MS,
algorithms: this.algorithms,
agentForward: false,
hostVerifier: (key: Buffer) => {
try {
settle({
ok: true,
value: describeSshHostKey(key)
})
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 主机密钥无效')
})
}
return false
}
})
})
}
testConnection(
host: ResolvedSshHost
): Promise<Omit<SshHostConnectionTestResult, 'hostId'>> {
if (!host.hostKey) {
return Promise.reject(
new Error('请先验证并接受 SSH 主机密钥')
)
}
return new Promise((resolve, reject) => {
const client = this.dependencies.createClient()
const startedAt = this.dependencies.now()
let settled = false
let hostKeyMismatch = false
const settle = (
outcome:
| {
ok: true
value: Omit<SshHostConnectionTestResult, 'hostId'>
}
| { ok: false; error: Error }
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
client.end()
if (outcome.ok) {
resolve(outcome.value)
} else {
reject(outcome.error)
}
}
let timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 连接超时')
}),
SSH_CONNECTION_TIMEOUT_MS
)
client.once('error', (error) => {
settle({
ok: false,
error: hostKeyMismatch
? new Error('SSH 主机密钥已变化,请重新验证主机身份')
: safeSshError(error)
})
})
client.once('ready', () => {
clearTimeout(timeout)
timeout = setTimeout(
() =>
settle({
ok: false,
error: new Error('SSH 远端系统探测超时')
}),
PROBE_TIMEOUT_MS
)
client.exec(PROBE_COMMAND, (error, stream) => {
if (error) {
settle({
ok: false,
error: new Error('无法启动 SSH 远端系统探测')
})
return
}
const chunks: Buffer[] = []
let outputBytes = 0
stream.on('data', (chunk: Buffer | string) => {
const buffer = Buffer.from(chunk)
outputBytes += buffer.byteLength
if (outputBytes > MAX_PROBE_OUTPUT_BYTES) {
stream.destroy()
settle({
ok: false,
error: new Error('SSH 远端系统探测输出超过安全限制')
})
return
}
chunks.push(buffer)
})
stream.once('error', () => {
settle({
ok: false,
error: new Error('SSH 远端系统探测失败')
})
})
stream.once('close', (code: number | null) => {
if (code !== 0) {
settle({
ok: false,
error: new Error('SSH 远端系统探测失败')
})
return
}
try {
settle({
ok: true,
value: parseProbeOutput(
Buffer.concat(chunks),
Math.max(0, this.dependencies.now() - startedAt)
)
})
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 远端系统探测失败')
})
}
})
})
})
let config: ConnectConfig
try {
config = buildAuthenticatedSshConnectConfig(
host,
this.algorithms,
this.dependencies.systemAgent(),
() => {
hostKeyMismatch = true
}
)
} catch (error) {
settle({
ok: false,
error:
error instanceof Error
? error
: new Error('SSH 认证配置无效')
})
return
}
client.connect(config)
})
}
}