2023-01-11 20:39:48 +08:00
|
|
|
|
// @ts-ignore
|
2024-11-29 19:00:05 +08:00
|
|
|
|
import path from "path";
|
2025-04-15 23:43:01 +08:00
|
|
|
|
import { isArray } from "lodash-es";
|
2025-04-30 09:38:44 +08:00
|
|
|
|
import { ILogger, safePromise } from "@certd/basic";
|
2024-11-29 19:00:05 +08:00
|
|
|
|
import { SshAccess } from "./ssh-access.js";
|
2025-04-15 23:43:01 +08:00
|
|
|
|
|
2025-03-24 23:45:45 +08:00
|
|
|
|
import fs from "fs";
|
2025-04-15 23:43:01 +08:00
|
|
|
|
import { SocksProxyType } from "socks/typings/common/constants";
|
2025-03-24 23:48:34 +08:00
|
|
|
|
|
2025-01-02 00:28:13 +08:00
|
|
|
|
export type TransportItem = { localPath: string; remotePath: string };
|
2025-04-15 23:43:01 +08:00
|
|
|
|
export interface SocksProxy {
|
|
|
|
|
|
ipaddress?: string;
|
|
|
|
|
|
host?: string;
|
|
|
|
|
|
port: number;
|
|
|
|
|
|
type: any;
|
|
|
|
|
|
userId?: string;
|
|
|
|
|
|
password?: string;
|
|
|
|
|
|
custom_auth_method?: number;
|
|
|
|
|
|
custom_auth_request_handler?: () => Promise<Buffer>;
|
|
|
|
|
|
custom_auth_response_size?: number;
|
|
|
|
|
|
custom_auth_response_handler?: (data: Buffer) => Promise<boolean>;
|
|
|
|
|
|
}
|
|
|
|
|
|
export type SshConnectConfig = {
|
|
|
|
|
|
sock?: any;
|
|
|
|
|
|
};
|
2024-09-29 11:50:59 +08:00
|
|
|
|
|
2024-06-25 12:25:57 +08:00
|
|
|
|
export class AsyncSsh2Client {
|
2025-04-15 23:43:01 +08:00
|
|
|
|
conn: any;
|
2024-06-25 12:25:57 +08:00
|
|
|
|
logger: ILogger;
|
2025-04-15 23:43:01 +08:00
|
|
|
|
connConf: SshAccess & SshConnectConfig;
|
2024-07-15 00:30:33 +08:00
|
|
|
|
windows = false;
|
|
|
|
|
|
encoding: string;
|
2024-06-27 16:38:43 +08:00
|
|
|
|
constructor(connConf: SshAccess, logger: ILogger) {
|
2024-06-25 12:25:57 +08:00
|
|
|
|
this.connConf = connConf;
|
|
|
|
|
|
this.logger = logger;
|
2024-06-27 16:38:43 +08:00
|
|
|
|
this.windows = connConf.windows || false;
|
|
|
|
|
|
this.encoding = connConf.encoding;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-09-04 18:29:39 +08:00
|
|
|
|
convert(iconv: any, buffer: Buffer) {
|
2024-07-15 00:30:33 +08:00
|
|
|
|
if (this.encoding) {
|
2024-06-27 16:38:43 +08:00
|
|
|
|
return iconv.decode(buffer, this.encoding);
|
|
|
|
|
|
}
|
2024-11-30 17:36:47 +08:00
|
|
|
|
return buffer.toString().replaceAll("\r\n", "\n");
|
2024-06-25 12:25:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async connect() {
|
|
|
|
|
|
this.logger.info(`开始连接,${this.connConf.host}:${this.connConf.port}`);
|
2024-09-29 11:50:59 +08:00
|
|
|
|
if (this.connConf.socksProxy) {
|
|
|
|
|
|
this.logger.info(`使用代理${this.connConf.socksProxy}`);
|
2024-11-29 19:00:05 +08:00
|
|
|
|
if (typeof this.connConf.port === "string") {
|
2024-09-29 11:50:59 +08:00
|
|
|
|
this.connConf.port = parseInt(this.connConf.port);
|
|
|
|
|
|
}
|
2025-04-15 23:43:01 +08:00
|
|
|
|
|
|
|
|
|
|
const { SocksClient } = await import("socks");
|
|
|
|
|
|
|
|
|
|
|
|
const proxyOption = this.parseSocksProxyFromUri(this.connConf.socksProxy);
|
2024-09-29 11:50:59 +08:00
|
|
|
|
const info = await SocksClient.createConnection({
|
|
|
|
|
|
proxy: proxyOption,
|
2024-11-29 19:00:05 +08:00
|
|
|
|
command: "connect",
|
2024-09-29 11:50:59 +08:00
|
|
|
|
destination: {
|
|
|
|
|
|
host: this.connConf.host,
|
|
|
|
|
|
port: this.connConf.port,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2024-11-29 19:00:05 +08:00
|
|
|
|
this.logger.info("代理连接成功");
|
2024-09-29 11:50:59 +08:00
|
|
|
|
this.connConf.sock = info.socket;
|
|
|
|
|
|
}
|
2025-04-02 00:20:09 +08:00
|
|
|
|
|
2025-04-15 23:43:01 +08:00
|
|
|
|
const ssh2 = await import("ssh2");
|
|
|
|
|
|
const ssh2Constants = await import("ssh2/lib/protocol/constants.js");
|
|
|
|
|
|
const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants.default;
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2024-07-18 21:10:13 +08:00
|
|
|
|
try {
|
2025-04-15 23:43:01 +08:00
|
|
|
|
const conn = new ssh2.default.Client();
|
2024-07-18 21:10:13 +08:00
|
|
|
|
conn
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("error", (err: any) => {
|
|
|
|
|
|
this.logger.error("连接失败", err);
|
2024-07-18 21:10:13 +08:00
|
|
|
|
reject(err);
|
|
|
|
|
|
})
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("ready", () => {
|
|
|
|
|
|
this.logger.info("连接成功");
|
2024-07-18 21:10:13 +08:00
|
|
|
|
this.conn = conn;
|
|
|
|
|
|
resolve(this.conn);
|
|
|
|
|
|
})
|
2025-04-02 00:20:09 +08:00
|
|
|
|
.on("keyboard-interactive", (name, descr, lang, prompts, finish) => {
|
|
|
|
|
|
// For illustration purposes only! It's not safe to do this!
|
|
|
|
|
|
// You can read it from process.stdin or whatever else...
|
|
|
|
|
|
const password = this.connConf.password;
|
|
|
|
|
|
return finish([password]);
|
|
|
|
|
|
|
|
|
|
|
|
// And remember, server may trigger this event multiple times
|
|
|
|
|
|
// and for different purposes (not only auth)
|
|
|
|
|
|
})
|
2025-03-21 01:02:57 +08:00
|
|
|
|
.connect({
|
|
|
|
|
|
...this.connConf,
|
2025-04-02 00:20:09 +08:00
|
|
|
|
tryKeyboard: true,
|
2025-03-21 01:02:57 +08:00
|
|
|
|
algorithms: {
|
2025-04-02 00:20:09 +08:00
|
|
|
|
serverHostKey: SUPPORTED_SERVER_HOST_KEY,
|
|
|
|
|
|
cipher: SUPPORTED_CIPHER,
|
|
|
|
|
|
hmac: SUPPORTED_MAC,
|
|
|
|
|
|
kex: SUPPORTED_KEX,
|
2025-03-21 01:02:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
2024-07-18 21:10:13 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reject(e);
|
|
|
|
|
|
}
|
2024-06-25 12:25:57 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
async getSftp() {
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
this.logger.info("获取sftp");
|
2024-06-25 12:25:57 +08:00
|
|
|
|
this.conn.sftp((err: any, sftp: any) => {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
resolve(sftp);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-20 23:30:54 +08:00
|
|
|
|
async fastPut(options: { sftp: any; localPath: string; remotePath: string; opts?: { mode?: string } }) {
|
2025-01-20 23:29:03 +08:00
|
|
|
|
const { sftp, localPath, remotePath, opts } = options;
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2024-06-26 13:58:17 +08:00
|
|
|
|
this.logger.info(`开始上传:${localPath} => ${remotePath}`);
|
2025-01-20 23:29:03 +08:00
|
|
|
|
sftp.fastPut(localPath, remotePath, { ...(opts ?? {}) }, (err: Error) => {
|
2024-06-25 12:25:57 +08:00
|
|
|
|
if (err) {
|
|
|
|
|
|
reject(err);
|
2024-11-29 19:00:05 +08:00
|
|
|
|
this.logger.error("请确认路径是否包含文件名,路径本身不能是目录,路径不能有*?之类的特殊符号,要有写入权限");
|
2024-06-25 12:25:57 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2024-06-26 13:58:17 +08:00
|
|
|
|
this.logger.info(`上传文件成功:${localPath} => ${remotePath}`);
|
2024-06-25 12:25:57 +08:00
|
|
|
|
resolve({});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-27 01:31:46 +08:00
|
|
|
|
async listDir(options: { sftp: any; remotePath: string }) {
|
|
|
|
|
|
const { sftp, remotePath } = options;
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2025-04-27 01:31:46 +08:00
|
|
|
|
this.logger.info(`listDir:${remotePath}`);
|
|
|
|
|
|
sftp.readdir(remotePath, (err: Error, list: any) => {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
resolve(list);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-02 00:28:13 +08:00
|
|
|
|
async unlink(options: { sftp: any; remotePath: string }) {
|
|
|
|
|
|
const { sftp, remotePath } = options;
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2025-01-02 00:28:13 +08:00
|
|
|
|
this.logger.info(`开始删除远程文件:${remotePath}`);
|
|
|
|
|
|
sftp.unlink(remotePath, (err: Error) => {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.logger.info(`删除文件成功:${remotePath}`);
|
|
|
|
|
|
resolve({});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-03 23:52:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param script
|
|
|
|
|
|
* @param opts {withStdErr 返回{stdOut,stdErr}}
|
|
|
|
|
|
*/
|
2024-11-30 17:36:47 +08:00
|
|
|
|
async exec(
|
|
|
|
|
|
script: string,
|
|
|
|
|
|
opts: {
|
|
|
|
|
|
throwOnStdErr?: boolean;
|
2025-06-03 23:52:43 +08:00
|
|
|
|
withStdErr?: boolean;
|
2025-04-08 18:06:12 +08:00
|
|
|
|
env?: any;
|
2024-11-30 17:36:47 +08:00
|
|
|
|
} = {}
|
|
|
|
|
|
): Promise<string> {
|
2024-08-05 16:00:04 +08:00
|
|
|
|
if (!script) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
this.logger.info("script 为空,取消执行");
|
2024-08-05 16:00:04 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2024-11-29 19:00:05 +08:00
|
|
|
|
let iconv: any = await import("iconv-lite");
|
2024-09-05 00:04:31 +08:00
|
|
|
|
iconv = iconv.default;
|
2024-11-29 19:00:05 +08:00
|
|
|
|
// if (this.connConf.windows) {
|
|
|
|
|
|
// script += "\r\nexit\r\n";
|
|
|
|
|
|
// //保证windows下正常退出
|
|
|
|
|
|
// }
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2024-10-16 12:20:42 +08:00
|
|
|
|
this.logger.info(`执行命令:[${this.connConf.host}][exec]: \n` + script);
|
2025-04-21 17:34:26 +08:00
|
|
|
|
// pty 伪终端,window下的输出会带上conhost.exe之类的多余的字符串,影响返回结果判断
|
|
|
|
|
|
// linux下 当使用keyboard-interactive 登录时,需要pty
|
2025-04-22 15:53:19 +08:00
|
|
|
|
const pty = this.connConf.pty; //linux下开启伪终端,windows下不开启
|
2025-04-21 17:34:26 +08:00
|
|
|
|
this.conn.exec(script, { pty, env: opts.env }, (err: Error, stream: any) => {
|
2024-06-25 12:25:57 +08:00
|
|
|
|
if (err) {
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2024-11-29 19:00:05 +08:00
|
|
|
|
let data = "";
|
2025-06-03 23:52:43 +08:00
|
|
|
|
let stdErr = "";
|
2024-11-30 17:36:47 +08:00
|
|
|
|
let hasErrorLog = false;
|
2024-06-25 12:25:57 +08:00
|
|
|
|
stream
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("close", (code: any, signal: any) => {
|
2024-06-25 12:25:57 +08:00
|
|
|
|
this.logger.info(`[${this.connConf.host}][close]:code:${code}`);
|
2025-07-09 15:43:25 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ]pipeline 执行命令:[10.123.0.2][exec]:cd /d D:\nginx-1.27.5 && D:\nginx-1.27.5\nginx.exe -t && D:\nginx-1.27.5\nginx.exe -s reload
|
|
|
|
|
|
* [2025-07-09T10:24:11.219] [ERROR]pipeline - [10. 123.0. 2][error]: nginx: the configuration file D: \nginx-1.27. 5/conf/nginx. conf syntax is ok
|
|
|
|
|
|
* [2025-07-09T10:24:11.231] [ERROR][10. 123. 0. 2] [error]: nginx: configuration file D: \nginx-1.27.5/conf/nginx.conf test is successful
|
|
|
|
|
|
* pipeline-
|
|
|
|
|
|
* [2025-07-09T10:24:11.473] [INFO]pipeline -[10.123.0.2][close]:code:0
|
|
|
|
|
|
* [2025-07-09T10:24:11.473][ERRoR] pipeline- [step][主机一执行远程主机脚本命令]<id:53hyarN3yvmbijNuMiNAt>: [Eror: nginx: the configuration fileD:\nginx-1.27.5/conf/nginx.conf syntax is ok
|
|
|
|
|
|
//需要忽略windows的错误
|
|
|
|
|
|
*/
|
|
|
|
|
|
// if (opts.throwOnStdErr == null && this.windows) {
|
|
|
|
|
|
// opts.throwOnStdErr = true;
|
|
|
|
|
|
// }
|
2024-11-30 17:36:47 +08:00
|
|
|
|
if (opts.throwOnStdErr && hasErrorLog) {
|
|
|
|
|
|
reject(new Error(data));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-06-25 12:25:57 +08:00
|
|
|
|
if (code === 0) {
|
2025-06-03 23:52:43 +08:00
|
|
|
|
if (opts.withStdErr === true) {
|
|
|
|
|
|
//@ts-ignore
|
|
|
|
|
|
resolve({
|
|
|
|
|
|
stdErr,
|
|
|
|
|
|
stdOut: data,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resolve(data);
|
|
|
|
|
|
}
|
2024-06-25 12:25:57 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
reject(new Error(data));
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("data", (ret: Buffer) => {
|
2024-09-04 18:29:39 +08:00
|
|
|
|
const out = this.convert(iconv, ret);
|
2024-07-15 00:30:33 +08:00
|
|
|
|
data += out;
|
2024-07-03 18:30:38 +08:00
|
|
|
|
this.logger.info(`[${this.connConf.host}][info]: ` + out.trimEnd());
|
2024-06-25 12:25:57 +08:00
|
|
|
|
})
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("error", (err: any) => {
|
2024-08-05 16:00:04 +08:00
|
|
|
|
reject(err);
|
|
|
|
|
|
this.logger.error(err);
|
|
|
|
|
|
})
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.stderr.on("data", (ret: Buffer) => {
|
2024-09-04 18:29:39 +08:00
|
|
|
|
const err = this.convert(iconv, ret);
|
2025-06-03 23:52:43 +08:00
|
|
|
|
stdErr += err;
|
2024-11-30 17:36:47 +08:00
|
|
|
|
hasErrorLog = true;
|
2025-08-19 17:06:14 +08:00
|
|
|
|
if (err.includes("sudo: a password is required")) {
|
|
|
|
|
|
this.logger.warn("请配置sudo免密,否则命令无法执行");
|
|
|
|
|
|
}
|
2024-11-30 17:36:47 +08:00
|
|
|
|
this.logger.error(`[${this.connConf.host}][error]: ` + err.trimEnd());
|
2024-06-25 12:25:57 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-30 17:36:47 +08:00
|
|
|
|
async shell(script: string | string[]): Promise<string> {
|
2025-04-15 23:43:01 +08:00
|
|
|
|
const stripAnsiModule = await import("strip-ansi");
|
|
|
|
|
|
const stripAnsi = stripAnsiModule.default;
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise<any>((resolve, reject) => {
|
2024-07-15 00:30:33 +08:00
|
|
|
|
this.logger.info(`执行shell脚本:[${this.connConf.host}][shell]: ` + script);
|
2024-06-25 12:25:57 +08:00
|
|
|
|
this.conn.shell((err: Error, stream: any) => {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2024-11-30 17:36:47 +08:00
|
|
|
|
let output = "";
|
2024-09-04 18:29:39 +08:00
|
|
|
|
function ansiHandle(data: string) {
|
2024-11-30 17:36:47 +08:00
|
|
|
|
data = data.replace(/\[[0-9]+;1H/g, "");
|
2024-09-04 18:29:39 +08:00
|
|
|
|
data = stripAnsi(data);
|
2024-11-30 17:36:47 +08:00
|
|
|
|
return data.replaceAll("\r\n", "\n");
|
2024-09-04 18:29:39 +08:00
|
|
|
|
}
|
2024-06-25 12:25:57 +08:00
|
|
|
|
stream
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("close", (code: any) => {
|
|
|
|
|
|
this.logger.info("Stream :: close,code: " + code);
|
2024-06-25 12:25:57 +08:00
|
|
|
|
resolve(output);
|
|
|
|
|
|
})
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("data", (ret: Buffer) => {
|
2024-09-04 18:29:39 +08:00
|
|
|
|
const data = ansiHandle(ret.toString());
|
|
|
|
|
|
this.logger.info(data);
|
2024-11-30 17:36:47 +08:00
|
|
|
|
output += data;
|
2024-06-27 16:38:43 +08:00
|
|
|
|
})
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.on("error", (err: any) => {
|
2024-09-04 18:29:39 +08:00
|
|
|
|
reject(err);
|
|
|
|
|
|
this.logger.error(err);
|
|
|
|
|
|
})
|
2024-11-29 19:00:05 +08:00
|
|
|
|
.stderr.on("data", (ret: Buffer) => {
|
2024-09-04 18:29:39 +08:00
|
|
|
|
const data = ansiHandle(ret.toString());
|
2024-11-30 17:36:47 +08:00
|
|
|
|
output += data;
|
|
|
|
|
|
this.logger.error(`[${this.connConf.host}][error]: ` + data);
|
2024-07-15 00:30:33 +08:00
|
|
|
|
});
|
2024-09-04 18:29:39 +08:00
|
|
|
|
//保证windows下正常退出
|
2024-11-29 19:00:05 +08:00
|
|
|
|
const exit = "\r\nexit\r\n";
|
2024-09-04 18:29:39 +08:00
|
|
|
|
stream.end(script + exit);
|
2024-06-25 12:25:57 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
end() {
|
|
|
|
|
|
if (this.conn) {
|
|
|
|
|
|
this.conn.end();
|
2024-10-15 19:27:55 +08:00
|
|
|
|
this.conn.destroy();
|
|
|
|
|
|
this.conn = null;
|
2024-06-25 12:25:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-09-29 11:50:59 +08:00
|
|
|
|
|
|
|
|
|
|
private parseSocksProxyFromUri(socksProxyUri: string): SocksProxy {
|
|
|
|
|
|
const url = new URL(socksProxyUri);
|
|
|
|
|
|
let type: SocksProxyType = 5;
|
2024-11-29 19:00:05 +08:00
|
|
|
|
if (url.protocol.startsWith("socks4")) {
|
2024-09-29 11:50:59 +08:00
|
|
|
|
type = 4;
|
|
|
|
|
|
}
|
|
|
|
|
|
const proxy: SocksProxy = {
|
|
|
|
|
|
host: url.hostname,
|
|
|
|
|
|
port: parseInt(url.port),
|
|
|
|
|
|
type,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (url.username) {
|
|
|
|
|
|
proxy.userId = url.username;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (url.password) {
|
|
|
|
|
|
proxy.password = url.password;
|
|
|
|
|
|
}
|
|
|
|
|
|
return proxy;
|
|
|
|
|
|
}
|
2025-04-27 01:31:46 +08:00
|
|
|
|
|
|
|
|
|
|
async download(param: { remotePath: string; savePath: string; sftp: any }) {
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2025-04-27 01:31:46 +08:00
|
|
|
|
const { remotePath, savePath, sftp } = param;
|
|
|
|
|
|
sftp.fastGet(
|
|
|
|
|
|
remotePath,
|
|
|
|
|
|
savePath,
|
|
|
|
|
|
{
|
|
|
|
|
|
step: (transferred: any, chunk: any, total: any) => {
|
|
|
|
|
|
this.logger.info(`${transferred} / ${total}`);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
(err: any) => {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resolve({});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2024-06-25 12:25:57 +08:00
|
|
|
|
}
|
2024-08-28 14:40:50 +08:00
|
|
|
|
|
2022-11-07 23:31:20 +08:00
|
|
|
|
export class SshClient {
|
2023-01-11 20:39:48 +08:00
|
|
|
|
logger: ILogger;
|
2022-11-07 23:31:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param connectConf
|
|
|
|
|
|
{
|
|
|
|
|
|
host: '192.168.100.100',
|
|
|
|
|
|
port: 22,
|
|
|
|
|
|
username: 'frylock',
|
|
|
|
|
|
password: 'nodejsrules'
|
|
|
|
|
|
}
|
|
|
|
|
|
* @param options
|
|
|
|
|
|
*/
|
2025-03-24 23:45:45 +08:00
|
|
|
|
async uploadFiles(options: { connectConf: SshAccess; transports: TransportItem[]; mkdirs: boolean; opts?: { mode?: string }; uploadType?: string }) {
|
2025-01-20 23:29:03 +08:00
|
|
|
|
const { connectConf, transports, mkdirs, opts } = options;
|
2024-06-25 12:25:57 +08:00
|
|
|
|
await this._call({
|
|
|
|
|
|
connectConf,
|
|
|
|
|
|
callable: async (conn: AsyncSsh2Client) => {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
this.logger.info("开始上传");
|
2025-03-24 23:45:45 +08:00
|
|
|
|
if (mkdirs !== false) {
|
|
|
|
|
|
this.logger.info("初始化父目录");
|
|
|
|
|
|
for (const transport of transports) {
|
2024-07-08 11:19:02 +08:00
|
|
|
|
const filePath = path.dirname(transport.remotePath);
|
|
|
|
|
|
let mkdirCmd = `mkdir -p ${filePath} `;
|
|
|
|
|
|
if (conn.windows) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
if (filePath.indexOf("/") > -1) {
|
|
|
|
|
|
this.logger.info("--------------------------");
|
|
|
|
|
|
this.logger.info("请注意:windows下,文件目录分隔应该写成\\而不是/");
|
|
|
|
|
|
this.logger.info("--------------------------");
|
2024-07-08 11:19:02 +08:00
|
|
|
|
}
|
2024-09-05 01:39:46 +08:00
|
|
|
|
const isCmd = await this.isCmd(conn);
|
|
|
|
|
|
if (!isCmd) {
|
2024-07-08 11:19:02 +08:00
|
|
|
|
mkdirCmd = `New-Item -ItemType Directory -Path "${filePath}" -Force`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
mkdirCmd = `if not exist "${filePath}" mkdir "${filePath}"`;
|
|
|
|
|
|
}
|
2024-07-03 18:30:38 +08:00
|
|
|
|
}
|
2024-10-15 19:27:55 +08:00
|
|
|
|
await conn.exec(mkdirCmd);
|
2024-06-27 16:38:43 +08:00
|
|
|
|
}
|
2024-06-25 12:25:57 +08:00
|
|
|
|
}
|
2025-03-24 23:45:45 +08:00
|
|
|
|
|
2025-04-27 01:31:46 +08:00
|
|
|
|
if (options.uploadType === "scp") {
|
2025-03-24 23:45:45 +08:00
|
|
|
|
//scp
|
|
|
|
|
|
for (const transport of transports) {
|
|
|
|
|
|
await this.scpUpload({ conn, ...transport, opts });
|
2025-03-26 21:48:51 +08:00
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
2025-03-24 23:45:45 +08:00
|
|
|
|
}
|
2025-04-27 01:31:46 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
const sftp = await conn.getSftp();
|
|
|
|
|
|
for (const transport of transports) {
|
|
|
|
|
|
await conn.fastPut({ sftp, ...transport, opts });
|
|
|
|
|
|
}
|
2025-03-24 23:45:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-29 19:00:05 +08:00
|
|
|
|
this.logger.info("文件全部上传成功");
|
2024-06-25 12:25:57 +08:00
|
|
|
|
},
|
2022-11-07 23:31:20 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-06-03 23:52:43 +08:00
|
|
|
|
constructor(logger: ILogger) {
|
|
|
|
|
|
this.logger = logger;
|
|
|
|
|
|
}
|
2022-11-07 23:31:20 +08:00
|
|
|
|
|
2025-03-24 23:45:45 +08:00
|
|
|
|
async scpUpload(options: { conn: any; localPath: string; remotePath: string; opts?: { mode?: string } }) {
|
2025-03-24 23:48:34 +08:00
|
|
|
|
const { conn, localPath, remotePath } = options;
|
2025-04-30 09:38:44 +08:00
|
|
|
|
return safePromise((resolve, reject) => {
|
2025-03-24 23:45:45 +08:00
|
|
|
|
// 关键步骤:构造 SCP 命令
|
2025-04-30 09:38:44 +08:00
|
|
|
|
this.logger.info(`开始上传:${localPath} => ${remotePath}`);
|
|
|
|
|
|
conn.conn.exec(
|
|
|
|
|
|
`scp -t ${remotePath}`, // -t 表示目标模式
|
|
|
|
|
|
(err, stream) => {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
return reject(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 准备 SCP 协议头
|
|
|
|
|
|
const fileStats = fs.statSync(localPath);
|
|
|
|
|
|
const fileName = path.basename(localPath);
|
2025-04-27 01:31:46 +08:00
|
|
|
|
|
2025-04-30 09:38:44 +08:00
|
|
|
|
// SCP 协议格式:C[权限] [文件大小] [文件名]\n
|
|
|
|
|
|
stream.write(`C0644 ${fileStats.size} ${fileName}\n`);
|
2025-04-27 01:31:46 +08:00
|
|
|
|
|
2025-04-30 09:38:44 +08:00
|
|
|
|
// 通过管道传输文件
|
|
|
|
|
|
fs.createReadStream(localPath)
|
|
|
|
|
|
.on("error", e => {
|
|
|
|
|
|
this.logger.info("read stream error", e);
|
|
|
|
|
|
reject(e);
|
|
|
|
|
|
})
|
|
|
|
|
|
.pipe(stream)
|
|
|
|
|
|
.on("finish", async () => {
|
|
|
|
|
|
this.logger.info(`上传完成:${localPath} => ${remotePath}`);
|
|
|
|
|
|
resolve(true);
|
|
|
|
|
|
})
|
|
|
|
|
|
.on("error", reject);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reject(e);
|
2025-03-24 23:45:45 +08:00
|
|
|
|
}
|
2025-04-30 09:38:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
);
|
2025-03-24 23:45:45 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-01-02 00:28:13 +08:00
|
|
|
|
async removeFiles(opts: { connectConf: SshAccess; files: string[] }) {
|
|
|
|
|
|
const { connectConf, files } = opts;
|
|
|
|
|
|
await this._call({
|
|
|
|
|
|
connectConf,
|
|
|
|
|
|
callable: async (conn: AsyncSsh2Client) => {
|
|
|
|
|
|
const sftp = await conn.getSftp();
|
|
|
|
|
|
this.logger.info("开始删除");
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
|
await conn.unlink({
|
|
|
|
|
|
sftp,
|
|
|
|
|
|
remotePath: file,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
this.logger.info("文件全部删除成功");
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-09-05 00:04:31 +08:00
|
|
|
|
async isCmd(conn: AsyncSsh2Client) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
const spec = await conn.exec("echo %COMSPEC% ");
|
|
|
|
|
|
if (spec.toString().trim() === "%COMSPEC%") {
|
2024-09-05 00:04:31 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-29 19:00:05 +08:00
|
|
|
|
async getIsCmd(options: { connectConf: SshAccess }) {
|
|
|
|
|
|
const { connectConf } = options;
|
2024-11-30 17:36:47 +08:00
|
|
|
|
return await this._call<boolean>({
|
2024-11-29 19:00:05 +08:00
|
|
|
|
connectConf,
|
|
|
|
|
|
callable: async (conn: AsyncSsh2Client) => {
|
|
|
|
|
|
return await this.isCmd(conn);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-09-05 00:04:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
*
|
|
|
|
|
|
* Set-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
|
|
|
|
|
|
* Start-Service sshd
|
|
|
|
|
|
*
|
|
|
|
|
|
* Set-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\Windows\System32\cmd.exe"
|
|
|
|
|
|
* @param options
|
|
|
|
|
|
*/
|
2025-07-11 17:37:33 +08:00
|
|
|
|
async exec(options: { connectConf: SshAccess; script: string | Array<string>; env?: any; throwOnStdErr?: boolean; stopOnError?: boolean }): Promise<string> {
|
2022-11-07 23:31:20 +08:00
|
|
|
|
let { script } = options;
|
2025-07-09 15:43:25 +08:00
|
|
|
|
const { connectConf, throwOnStdErr } = options;
|
2024-09-05 00:04:31 +08:00
|
|
|
|
|
2024-10-16 12:20:42 +08:00
|
|
|
|
// this.logger.info('命令:', script);
|
2024-06-25 12:25:57 +08:00
|
|
|
|
return await this._call({
|
|
|
|
|
|
connectConf,
|
|
|
|
|
|
callable: async (conn: AsyncSsh2Client) => {
|
2024-09-05 00:04:31 +08:00
|
|
|
|
let isWinCmd = false;
|
2024-10-10 16:18:37 +08:00
|
|
|
|
const isLinux = !connectConf.windows;
|
|
|
|
|
|
const envScripts = [];
|
2024-09-05 00:04:31 +08:00
|
|
|
|
if (connectConf.windows) {
|
|
|
|
|
|
isWinCmd = await this.isCmd(conn);
|
|
|
|
|
|
}
|
2024-10-10 16:18:37 +08:00
|
|
|
|
|
|
|
|
|
|
if (options.env) {
|
|
|
|
|
|
for (const key in options.env) {
|
|
|
|
|
|
if (isLinux) {
|
|
|
|
|
|
envScripts.push(`export ${key}=${options.env[key]}`);
|
|
|
|
|
|
} else if (isWinCmd) {
|
|
|
|
|
|
//win cmd
|
|
|
|
|
|
envScripts.push(`set ${key}=${options.env[key]}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
//powershell
|
|
|
|
|
|
envScripts.push(`$env:${key}="${options.env[key]}"`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-09-05 00:04:31 +08:00
|
|
|
|
if (isWinCmd) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
if (typeof script === "string") {
|
|
|
|
|
|
script = script.split("\n");
|
2024-09-05 00:04:31 +08:00
|
|
|
|
}
|
2025-07-15 16:41:15 +08:00
|
|
|
|
//组合成&&的形式
|
2024-10-10 16:18:37 +08:00
|
|
|
|
script = envScripts.concat(script);
|
2024-09-05 00:04:31 +08:00
|
|
|
|
script = script as Array<string>;
|
2024-11-29 19:00:05 +08:00
|
|
|
|
script = script.join(" && ");
|
2024-09-05 00:04:31 +08:00
|
|
|
|
} else {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
const newLine = isLinux ? "\n" : "\r\n";
|
2025-04-15 23:43:01 +08:00
|
|
|
|
if (isArray(script)) {
|
2024-09-05 00:04:31 +08:00
|
|
|
|
script = script as Array<string>;
|
2024-11-29 19:00:05 +08:00
|
|
|
|
script = script.join(newLine);
|
2024-09-05 00:04:31 +08:00
|
|
|
|
}
|
2024-10-10 16:18:37 +08:00
|
|
|
|
if (envScripts.length > 0) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
script = envScripts.join(newLine) + newLine + script;
|
2024-10-10 16:18:37 +08:00
|
|
|
|
}
|
2024-09-05 00:04:31 +08:00
|
|
|
|
}
|
2025-07-11 17:37:33 +08:00
|
|
|
|
|
2025-07-15 16:41:15 +08:00
|
|
|
|
if (isLinux && options.stopOnError !== false) {
|
|
|
|
|
|
script = "set -e\n" + script;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-09 15:43:25 +08:00
|
|
|
|
return await conn.exec(script as string, { throwOnStdErr });
|
2024-06-25 12:25:57 +08:00
|
|
|
|
},
|
2022-11-07 23:31:20 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-30 17:36:47 +08:00
|
|
|
|
async shell(options: { connectConf: SshAccess; script: string | Array<string> }): Promise<string> {
|
2024-09-04 18:29:39 +08:00
|
|
|
|
let { script } = options;
|
|
|
|
|
|
const { connectConf } = options;
|
2025-04-15 23:43:01 +08:00
|
|
|
|
if (isArray(script)) {
|
2024-09-04 18:29:39 +08:00
|
|
|
|
script = script as Array<string>;
|
|
|
|
|
|
if (connectConf.windows) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
script = script.join("\r\n");
|
2024-09-04 18:29:39 +08:00
|
|
|
|
} else {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
script = script.join("\n");
|
2024-09-04 18:29:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (connectConf.windows) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
//@ts-ignore
|
|
|
|
|
|
script = script.replaceAll("\n", "\r\n");
|
2024-09-04 18:29:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-06-25 12:25:57 +08:00
|
|
|
|
return await this._call({
|
|
|
|
|
|
connectConf,
|
|
|
|
|
|
callable: async (conn: AsyncSsh2Client) => {
|
|
|
|
|
|
return await conn.shell(script as string);
|
|
|
|
|
|
},
|
2022-11-07 23:31:20 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-11-30 17:36:47 +08:00
|
|
|
|
async _call<T = any>(options: { connectConf: SshAccess; callable: (conn: AsyncSsh2Client) => Promise<T> }): Promise<T> {
|
2024-06-25 12:25:57 +08:00
|
|
|
|
const { connectConf, callable } = options;
|
|
|
|
|
|
const conn = new AsyncSsh2Client(connectConf, this.logger);
|
2024-09-20 11:11:25 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await conn.connect();
|
|
|
|
|
|
} catch (e: any) {
|
2024-11-29 19:00:05 +08:00
|
|
|
|
if (e.message?.indexOf("All configured authentication methods failed") > -1) {
|
2024-09-20 11:11:25 +08:00
|
|
|
|
this.logger.error(e);
|
2024-11-29 19:00:05 +08:00
|
|
|
|
throw new Error("登录失败,请检查用户名/密码/密钥是否正确");
|
2024-09-20 11:11:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
throw e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-06-25 12:25:57 +08:00
|
|
|
|
try {
|
|
|
|
|
|
return await callable(conn);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
conn.end();
|
|
|
|
|
|
}
|
2022-11-07 23:31:20 +08:00
|
|
|
|
}
|
2025-04-27 01:31:46 +08:00
|
|
|
|
|
|
|
|
|
|
async listDir(param: { connectConf: any; dir: string }) {
|
|
|
|
|
|
return await this._call<any>({
|
|
|
|
|
|
connectConf: param.connectConf,
|
|
|
|
|
|
callable: async (conn: AsyncSsh2Client) => {
|
|
|
|
|
|
const sftp = await conn.getSftp();
|
|
|
|
|
|
return await conn.listDir({
|
|
|
|
|
|
sftp,
|
|
|
|
|
|
remotePath: param.dir,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async download(param: { connectConf: any; filePath: string; savePath: string }) {
|
|
|
|
|
|
return await this._call<any>({
|
|
|
|
|
|
connectConf: param.connectConf,
|
|
|
|
|
|
callable: async (conn: AsyncSsh2Client) => {
|
|
|
|
|
|
const sftp = await conn.getSftp();
|
|
|
|
|
|
return await conn.download({
|
|
|
|
|
|
sftp,
|
|
|
|
|
|
remotePath: param.filePath,
|
|
|
|
|
|
savePath: param.savePath,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2022-11-07 23:31:20 +08:00
|
|
|
|
}
|