node-ssh、ssh2、ssh2-promise 和 ssh2-sftp-client 都是用于在 Node.js 环境中建立 SSH 或 SFTP 连接的 npm 包。它们允许开发者远程执行命令、传输文件或管理服务器资源。其中 ssh2 是底层核心实现,其他包多基于它进行封装以提供更易用的 API。这些库广泛应用于自动化部署、远程运维脚本、CI/CD 流水线及文件同步等场景。
在 Node.js 中实现 SSH 或 SFTP 功能时,开发者常面临多个库的选择。虽然它们底层大多基于 ssh2,但抽象层级、API 设计和适用场景差异显著。本文从真实工程角度出发,深入比较 node-ssh、ssh2、ssh2-promise 和 ssh2-sftp-client 的技术细节,帮助你做出合理选型。
所有四个包都直接或间接依赖于 ssh2 —— 这是由 mscdex 维护的、功能完整的纯 JavaScript SSH2 客户端与服务器实现。它是事实上的底层标准。
ssh2:活跃维护,提供最底层、最完整的控制能力。node-ssh:基于 ssh2 封装,提供更简洁的 Promise API,持续更新。ssh2-promise:对 ssh2 的 Promise 包装,但 自 2019 年起已不再维护,GitHub 仓库归档,npm 页面无明确弃用标记但实际应视为废弃。ssh2-sftp-client:专精 SFTP 场景,基于 ssh2 构建,仅暴露 SFTP 相关操作,维护活跃。⚠️ 注意:
ssh2-promise不应再用于新项目。其 API 已过时,且无法获得安全更新或 bug 修复。
ssh2(底层原生)ssh2 使用事件驱动模型,需手动处理连接、认证、会话创建等流程。适合需要精细控制连接生命周期的场景。
const { Client } = require('ssh2');
const conn = new Client();
conn
.on('ready', () => {
conn.exec('uptime', (err, stream) => {
if (err) throw err;
stream
.on('close', () => conn.end())
.on('data', (data) => console.log(data.toString()));
});
})
.connect({
host: 'example.com',
username: 'user',
password: 'pass'
});
node-ssh(高层封装)提供链式 Promise API,自动管理连接和会话,代码简洁。
const { NodeSSH } = require('node-ssh');
const ssh = new NodeSSH();
await ssh.connect({
host: 'example.com',
username: 'user',
password: 'pass'
});
const result = await ssh.execCommand('uptime');
console.log(result.stdout);
await ssh.dispose();
ssh2-promise(已废弃)曾提供类似 node-ssh 的 Promise 接口,但因长期未维护,不推荐使用。示例仅作历史参考:
// ❌ 不要在新项目中使用
const SSH = require('ssh2-promise');
const ssh = new SSH({
host: 'example.com',
username: 'user',
password: 'pass'
});
const res = await ssh.exec('uptime');
console.log(res); // 返回 stdout/stderr 对象
ssh2-sftp-client(SFTP 专用)不支持普通 shell 命令执行,仅用于文件传输。若需执行命令,必须搭配其他库。
// ❌ 无法执行 exec('uptime')
const Client = require('ssh2-sftp-client');
const sftp = new Client();
await sftp.connect({
host: 'example.com',
username: 'user',
password: 'pass'
});
// 只能做文件操作
await sftp.list('/remote/path');
await sftp.end();
ssh2 需通过 sftp() 方法获取 SFTP 会话,再调用 fastPut/fastGet 等方法:
conn.on('ready', () => {
conn.sftp((err, sftp) => {
if (err) throw err;
sftp.fastPut('local.txt', '/remote.txt', (err) => {
if (err) throw err;
conn.end();
});
});
});
node-ssh 提供 putFile/getFile 等高层方法,自动处理 SFTP 会话:
await ssh.putFile('local.txt', '/remote.txt');
await ssh.getFile('downloaded.txt', '/remote.txt');
ssh2-sftp-client 是专为 SFTP 设计的,API 更丰富(如 uploadDir, downloadDir, append 等):
await sftp.upload('local.txt', '/remote.txt');
await sftp.download('/remote.txt', 'downloaded.txt');
💡 提示:
node-ssh的put方法支持 Buffer、Stream 或本地路径;ssh2-sftp-client同样支持多种输入源,但对目录递归操作支持更完善。
处理大文件时,流式传输可避免内存溢出。
ssh2 原生支持 Stream:
const readStream = fs.createReadStream('large-file.zip');
conn.sftp((err, sftp) => {
const writeStream = sftp.createWriteStream('/remote/large-file.zip');
readStream.pipe(writeStream);
});
node-ssh 支持传入 ReadableStream:
const stream = fs.createReadStream('large-file.zip');
await ssh.put(stream, '/remote/large-file.zip');
ssh2-sftp-client 也支持 Stream,并额外提供进度回调:
await sftp.upload(
fs.createReadStream('large-file.zip'),
'/remote/large-file.zip',
{ progress: (progress) => console.log(`Uploaded: ${progress}`) }
);
ssh2 要求开发者手动监听 error、end、close 等事件,容易遗漏导致内存泄漏。
node-ssh 和 ssh2-sftp-client 在内部封装了错误传播,Promise 会 reject 异常,且提供 dispose() 或 end() 方法确保资源释放。
// node-ssh
try {
await ssh.execCommand('invalid-command');
} catch (err) {
console.error('Command failed:', err.message);
} finally {
await ssh.dispose();
}
// ssh2-sftp-client
try {
await sftp.list('/nonexistent');
} catch (err) {
console.error('SFTP error:', err.message);
} finally {
await sftp.end();
}
| 场景 | 推荐库 | 理由 |
|---|---|---|
| 通用 SSH + SFTP 操作,追求开发效率 | node-ssh | API 简洁,Promise 友好,覆盖命令执行与文件传输 |
| 仅需 SFTP 文件操作,需高级功能(如进度、目录同步) | ssh2-sftp-client | 专注 SFTP,功能完整,错误处理健壮 |
| 需要底层控制(如自定义加密算法、代理跳转、多通道复用) | ssh2 | 提供最细粒度的 SSH 协议控制 |
| 新项目 | 避免 ssh2-promise | 已停止维护,存在安全与兼容风险 |
node-ssh:对于大多数前端团队涉及的自动化部署、远程脚本执行、简单文件同步等任务,它提供了最佳平衡 —— 简单、安全、功能完整。ssh2-sftp-client:如果你只做文件上传下载、目录同步,它的 API 更贴合需求,且支持进度监控等实用功能。ssh2:除非你需要实现非标准 SSH 行为(如 SSH 代理链、自定义认证方式),否则高层封装已足够。ssh2-promise:即使代码能跑,长期维护成本和安全风险极高。这四个库并非竞争关系,而是不同抽象层级的工具。ssh2 是引擎,node-ssh 和 ssh2-sftp-client 是为不同用途定制的车身。根据你的具体需求 —— 是要一辆全能轿车(node-ssh),还是一辆专业货车(ssh2-sftp-client),或是自己造车(ssh2)—— 做出选择即可。记住:简单场景用高层封装,复杂协议需求才下沉到底层。
选择 node-ssh 如果你需要一个兼顾 SSH 命令执行和 SFTP 文件传输的高层封装库,且希望使用简洁的 Promise API。它适合大多数通用场景,如自动化部署、远程脚本运行和简单文件同步,能显著减少样板代码并自动管理连接资源。
选择 ssh2 如果你需要对 SSH 连接进行精细控制,例如实现非标准认证方式、自定义加密算法、SSH 代理跳转或多通道复用。它提供最完整的底层能力,但要求开发者手动处理事件、错误和资源释放,适合高级用户或特殊协议需求。
不要在新项目中使用 ssh2-promise。该包自 2019 年起已停止维护,GitHub 仓库已归档,存在安全漏洞和兼容性风险。即使其 Promise API 曾经方便,也应优先评估 node-ssh 或 ssh2-sftp-client 等活跃替代方案。
选择 ssh2-sftp-client 如果你的需求仅限于 SFTP 文件操作(如上传、下载、目录同步),且需要高级功能如进度回调、流式传输或递归目录处理。它不支持普通 shell 命令执行,但在纯文件传输场景下比通用库更专注、功能更完整。
Node-SSH is an extremely lightweight Promise wrapper for ssh2.
$ npm install node-ssh # If you're using npm
$ yarn add node-ssh # If you're using Yarn
const fs = require('fs')
const path = require('path')
const {NodeSSH} = require('node-ssh')
const ssh = new NodeSSH()
ssh.connect({
host: 'localhost',
username: 'steel',
privateKeyPath: '/home/steel/.ssh/id_rsa'
})
// or with inline privateKey
ssh.connect({
host: 'localhost',
username: 'steel',
privateKey: Buffer.from('...')
})
.then(function() {
// Local, Remote
ssh.putFile('/home/steel/Lab/localPath/fileName', '/home/steel/Lab/remotePath/fileName').then(function() {
console.log("The File thing is done")
}, function(error) {
console.log("Something's wrong")
console.log(error)
})
// Array<Shape('local' => string, 'remote' => string)>
ssh.putFiles([{ local: '/home/steel/Lab/localPath/fileName', remote: '/home/steel/Lab/remotePath/fileName' }]).then(function() {
console.log("The File thing is done")
}, function(error) {
console.log("Something's wrong")
console.log(error)
})
// Local, Remote
ssh.getFile('/home/steel/Lab/localPath', '/home/steel/Lab/remotePath').then(function(Contents) {
console.log("The File's contents were successfully downloaded")
}, function(error) {
console.log("Something's wrong")
console.log(error)
})
// Putting entire directories
const failed = []
const successful = []
ssh.putDirectory('/home/steel/Lab', '/home/steel/Lab', {
recursive: true,
concurrency: 10,
// ^ WARNING: Not all servers support high concurrency
// try a bunch of values and see what works on your server
validate: function(itemPath) {
const baseName = path.basename(itemPath)
return baseName.substr(0, 1) !== '.' && // do not allow dot files
baseName !== 'node_modules' // do not allow node_modules
},
tick: function(localPath, remotePath, error) {
if (error) {
failed.push(localPath)
} else {
successful.push(localPath)
}
}
}).then(function(status) {
console.log('the directory transfer was', status ? 'successful' : 'unsuccessful')
console.log('failed transfers', failed.join(', '))
console.log('successful transfers', successful.join(', '))
})
// Command
ssh.execCommand('hh_client --json', { cwd:'/var/www' }).then(function(result) {
console.log('STDOUT: ' + result.stdout)
console.log('STDERR: ' + result.stderr)
})
// Command with escaped params
ssh.exec('hh_client', ['--json'], { cwd: '/var/www', stream: 'stdout', options: { pty: true } }).then(function(result) {
console.log('STDOUT: ' + result)
})
// With streaming stdout/stderr callbacks
ssh.exec('hh_client', ['--json'], {
cwd: '/var/www',
onStdout(chunk) {
console.log('stdoutChunk', chunk.toString('utf8'))
},
onStderr(chunk) {
console.log('stderrChunk', chunk.toString('utf8'))
},
})
})
// API reference in Typescript typing format:
import SSH2, {
AcceptConnection,
Channel,
ClientChannel,
ConnectConfig,
ExecOptions,
Prompt,
PseudoTtyOptions,
RejectConnection,
SFTPWrapper,
ShellOptions,
TcpConnectionDetails,
TransferOptions,
UNIXConnectionDetails,
} from 'ssh2'
import stream from 'stream'
// ^ You do NOT need to import these package, these are here for reference of where the
// types are coming from.
export type Config = ConnectConfig & {
password?: string
privateKey?: string
privateKeyPath?: string
tryKeyboard?: boolean
onKeyboardInteractive?: (
name: string,
instructions: string,
lang: string,
prompts: Prompt[],
finish: (responses: string[]) => void,
) => void
}
export interface SSHExecCommandOptions {
cwd?: string
stdin?: string | stream.Readable
execOptions?: ExecOptions
encoding?: BufferEncoding
noTrim?: boolean
onChannel?: (clientChannel: ClientChannel) => void
onStdout?: (chunk: Buffer) => void
onStderr?: (chunk: Buffer) => void
}
export interface SSHExecCommandResponse {
stdout: string
stderr: string
code: number | null
signal: string | null
}
export interface SSHExecOptions extends SSHExecCommandOptions {
stream?: 'stdout' | 'stderr' | 'both'
}
export interface SSHPutFilesOptions {
sftp?: SFTPWrapper | null
concurrency?: number
transferOptions?: TransferOptions
}
export interface SSHGetPutDirectoryOptions extends SSHPutFilesOptions {
tick?: (localFile: string, remoteFile: string, error: Error | null) => void
validate?: (path: string) => boolean
recursive?: boolean
}
export type SSHMkdirMethod = 'sftp' | 'exec'
export type SSHForwardInListener = (
details: TcpConnectionDetails,
accept: AcceptConnection<ClientChannel>,
reject: RejectConnection,
) => void
export interface SSHForwardInDetails {
dispose(): Promise<void>
port: number
}
export type SSHForwardInStreamLocalListener = (
info: UNIXConnectionDetails,
accept: AcceptConnection,
reject: RejectConnection,
) => void
export interface SSHForwardInStreamLocalDetails {
dispose(): Promise<void>
}
export class SSHError extends Error {
code: string | null
constructor(message: string, code?: string | null)
}
export class NodeSSH {
connection: SSH2.Client | null
connect(config: Config): Promise<this>
isConnected(): boolean
requestShell(options?: PseudoTtyOptions | ShellOptions | false): Promise<ClientChannel>
withShell(
callback: (channel: ClientChannel) => Promise<void>,
options?: PseudoTtyOptions | ShellOptions | false,
): Promise<void>
requestSFTP(): Promise<SFTPWrapper>
withSFTP(callback: (sftp: SFTPWrapper) => Promise<void>): Promise<void>
execCommand(givenCommand: string, options?: SSHExecCommandOptions): Promise<SSHExecCommandResponse>
exec(
command: string,
parameters: string[],
options?: SSHExecOptions & {
stream?: 'stdout' | 'stderr'
},
): Promise<string>
exec(
command: string,
parameters: string[],
options?: SSHExecOptions & {
stream: 'both'
},
): Promise<SSHExecCommandResponse>
mkdir(path: string, method?: SSHMkdirMethod, givenSftp?: SFTPWrapper | null): Promise<void>
getFile(
localFile: string,
remoteFile: string,
givenSftp?: SFTPWrapper | null,
transferOptions?: TransferOptions | null,
): Promise<void>
putFile(
localFile: string,
remoteFile: string,
givenSftp?: SFTPWrapper | null,
transferOptions?: TransferOptions | null,
): Promise<void>
putFiles(
files: {
local: string
remote: string
}[],
{ concurrency, sftp: givenSftp, transferOptions }?: SSHPutFilesOptions,
): Promise<void>
putDirectory(
localDirectory: string,
remoteDirectory: string,
{ concurrency, sftp: givenSftp, transferOptions, recursive, tick, validate }?: SSHGetPutDirectoryOptions,
): Promise<boolean>
getDirectory(
localDirectory: string,
remoteDirectory: string,
{ concurrency, sftp: givenSftp, transferOptions, recursive, tick, validate }?: SSHGetPutDirectoryOptions,
): Promise<boolean>
forwardIn(remoteAddr: string, remotePort: number, onConnection?: SSHForwardInListener): Promise<SSHForwardInDetails>
forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number): Promise<Channel>
forwardInStreamLocal(
socketPath: string,
onConnection?: SSHForwardInStreamLocalListener,
): Promise<SSHForwardInStreamLocalDetails>
forwardOutStreamLocal(socketPath: string): Promise<Channel>
dispose(): void
}
node-ssh requires extra dependencies while working under Typescript. Please install them as shown below
yarn add --dev @types/ssh2
# OR
npm install --save-dev @types/ssh2
If you're still running into issues, try adding these to your tsconfig.json
{
"compilerOptions": {
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
}
}
In some cases you have to enable keyboard-interactive user authentication.
Otherwise you will get an All configured authentication methods failed error.
const password = 'test'
ssh.connect({
host: 'localhost',
username: 'steel',
port: 22,
password,
tryKeyboard: true,
})
// Or if you want to add some custom keyboard-interactive logic:
ssh.connect({
host: 'localhost',
username: 'steel',
port: 22,
tryKeyboard: true,
onKeyboardInteractive(name, instructions, instructionsLang, prompts, finish) {
if (prompts.length > 0 && prompts[0].prompt.toLowerCase().includes('password')) {
finish([password])
}
}
})
For further information see: https://github.com/mscdex/ssh2/issues/604
This project is licensed under the terms of MIT license. See the LICENSE file for more info.