From 05b6159802b9e85b6a410361b60b5c28875b48e7 Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Mon, 24 Mar 2025 23:45:45 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=B8=8A=E4=BC=A0=E5=88=B0=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E6=94=AF=E6=8C=81scp=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/plugin-lib/src/scp/constant.ts | 35 + packages/plugins/plugin-lib/src/scp/index.ts | 568 ++++++++++++++++ packages/plugins/plugin-lib/src/scp/types.ts | 124 ++++ packages/plugins/plugin-lib/src/scp/utils.ts | 639 ++++++++++++++++++ packages/plugins/plugin-lib/src/ssh/ssh.ts | 59 +- .../plugin/upload-to-host/index.ts | 40 +- 6 files changed, 1438 insertions(+), 27 deletions(-) create mode 100644 packages/plugins/plugin-lib/src/scp/constant.ts create mode 100644 packages/plugins/plugin-lib/src/scp/index.ts create mode 100644 packages/plugins/plugin-lib/src/scp/types.ts create mode 100644 packages/plugins/plugin-lib/src/scp/utils.ts diff --git a/packages/plugins/plugin-lib/src/scp/constant.ts b/packages/plugins/plugin-lib/src/scp/constant.ts new file mode 100644 index 00000000..89b7cce4 --- /dev/null +++ b/packages/plugins/plugin-lib/src/scp/constant.ts @@ -0,0 +1,35 @@ +export enum errorCode { + generic = "ERR_GENERIC_CLIENT", + connect = "ERR_NOT_CONNECTED", + badPath = "ERR_BAD_PATH", + permission = "EACCES", + notexist = "ENOENT", + notdir = "ENOTDIR", +} + +export enum targetType { + writeFile = 1, + readFile = 2, + writeDir = 3, + readDir = 4, + readObj = 5, + writeObj = 6, +} + +export const CLIENT_EVENTS = new Set([ + "banner", + "ready", + "tcp connection", + "x11", + "keyboard-interactive", + "change password", + "error", + "end", + "close", + "timeout", + "connect", + "greeting", + "handshake", + "hostkeys", + "unix connection", +]); diff --git a/packages/plugins/plugin-lib/src/scp/index.ts b/packages/plugins/plugin-lib/src/scp/index.ts new file mode 100644 index 00000000..751fa3fa --- /dev/null +++ b/packages/plugins/plugin-lib/src/scp/index.ts @@ -0,0 +1,568 @@ +import { EventEmitter } from "events"; +import { mkdirSync, readdirSync, existsSync } from "fs"; +import { join, win32, posix } from "path"; +import { Client as SSHClient, ConnectConfig, InputAttributes, SFTPWrapper, Stats, TransferOptions, WriteFileOptions, ParsedKey, UNIXConnectionDetails, AcceptConnection, RejectConnection } from "ssh2"; +import { targetType } from "./constant.js"; +import * as utils from "./utils.js"; +import { ClientEvents } from "./types.js"; + +export type TScpOptions = ConnectConfig & { + remoteOsType?: "posix" | "win32"; + events?: ClientEvents; +}; + +export class ScpClient extends EventEmitter { + sftpWrapper: SFTPWrapper | null = null; + sshClient: SSHClient | null = null; + remotePathSep = posix.sep; + endCalled = false; + errorHandled = false; + + constructor(options: TScpOptions) { + super(); + + const ssh = new SSHClient(); + ssh + .on("connect", () => this.emit("connect")) + .on("ready", () => { + ssh.sftp((err, sftp) => { + if (err) { + throw err; + } + // save for reuse + this.sftpWrapper = sftp; + this.emit("ready"); + }); + }) + .on("error", err => this.emit("error", err)) + .on("end", () => this.emit("end")) + .on("close", () => { + if (!this.endCalled) { + this.sftpWrapper = null; + } + this.emit("close"); + }) + .on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => this.emit("keyboard-interactive", name, instructions, instructionsLang, prompts, finish)) + .on("change password", (message, done) => this.emit("change password", message, done)) + .on("tcp connection", (details, accept, reject) => this.emit("tcp connection", details, accept, reject)) + .on("banner", message => this.emit("banner", message)) + .on("greeting", greeting => this.emit("banner", greeting)) + .on("handshake", negotiated => this.emit("handshake", negotiated)) + .on("hostkeys", (keys: ParsedKey[]) => this.emit("hostkeys", keys)) + .on("timeout", () => this.emit("timeout")) + .on("unix connection", (info: UNIXConnectionDetails, accept: AcceptConnection, reject: RejectConnection) => this.emit("unix connection", info, accept, reject)) + .on("x11", message => this.emit("x11", message)); + ssh.connect(options); + this.sshClient = ssh; + + if (options.remoteOsType === "win32") { + this.remotePathSep = win32.sep; + } + } + + /** + * Uploads a file from `localPath` to `remotePath` using parallel reads for faster throughput. + */ + public async uploadFile(localPath: string, remotePath: string, options: TransferOptions = {}): Promise { + utils.haveConnection(this, "uploadFile"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.fastPut(localPath, remotePath, options, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Downloads a file at `remotePath` to `localPath` using parallel reads for faster throughput. + */ + public async downloadFile(remotePath: string, localPath: string, options: TransferOptions = {}): Promise { + utils.haveConnection(this, "downloadFile"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.fastGet(remotePath, localPath, options, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Clean a directory in remote server + */ + public async emptyDir(dir: string): Promise { + utils.haveConnection(this, "uploadDir"); + try { + const isExist = await this.exists(dir); + + if (!isExist) { + await this.mkdir(dir); + } else if (isExist === "d") { + await this.rmdir(dir); + await this.mkdir(dir); + } + } catch (error) { + throw error; + } + } + + public async uploadDir(src: string, dest: string): Promise { + utils.haveConnection(this, "uploadDir"); + try { + const isExist = await this.exists(dest); + + if (!isExist) { + await this.mkdir(dest); + } + + const dirEntries = readdirSync(src, { + encoding: "utf8", + withFileTypes: true, + }); + + for (const e of dirEntries) { + if (e.isDirectory()) { + const newSrc = join(src, e.name); + const newDst = utils.joinRemote(this, dest, e.name); + await this.uploadDir(newSrc, newDst); + } else if (e.isFile()) { + const newSrc = join(src, e.name); + const newDst = utils.joinRemote(this, dest, e.name); + await this.uploadFile(newSrc, newDst); + + // this.client.emit('upload', {source: src, destination: dst}) + } + } + } catch (error) { + throw error; + } + } + + public async downloadDir(remotePath: string, localPath: string) { + utils.haveConnection(this, "downloadDir"); + const remoteInfo: any = await utils.checkRemotePath(this, remotePath, targetType.readDir); + if (!remoteInfo.valid) { + throw new Error(remoteInfo.msg); + } + + if (!existsSync(localPath)) { + mkdirSync(localPath); + } + + const localInfo = await utils.checkLocalPath(localPath, targetType.writeDir); + + if (localInfo.valid && !localInfo.type) { + mkdirSync(localInfo.path, { recursive: true }); + } + + if (!localInfo.valid) { + throw new Error(localInfo.msg); + } + const fileList = await this.list(remoteInfo.path); + for (const f of fileList) { + if (f.type === "d") { + const newSrc = remoteInfo.path + this.remotePathSep + f.name; + const newDst = join(localInfo.path, f.name); + await this.downloadDir(newSrc, newDst); + } else if (f.type === "-") { + const src = remoteInfo.path + this.remotePathSep + f.name; + const dst = join(localInfo.path, f.name); + await this.downloadFile(src, dst); + this.sshClient!.emit("download", { source: src, destination: dst }); + } else { + console.log(`downloadDir: File ignored: ${f.name} not regular file`); + } + } + return `${remoteInfo.path} downloaded to ${localInfo.path}`; + } + + /** + * Retrieves attributes for `path`. + */ + public async stat(remotePath: string): Promise { + utils.haveConnection(this, "stat"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.stat(remotePath, (err, stats) => { + if (err) { + reject(err); + } else { + resolve(stats); + } + }); + }); + } + + /** + * Sets the attributes defined in `attributes` for `path`. + */ + public async setstat(path: string, attributes: InputAttributes = {}): Promise { + utils.haveConnection(this, "setstat"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.setstat(path, attributes, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Removes the file/symlink at `path`. + */ + public async unlink(remotePath: string): Promise { + utils.haveConnection(this, "unlink"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.unlink(remotePath, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + // _rmdir - only works with an empty directory + async _rmdir(remotePath: string): Promise { + return new Promise(async (resolve, reject) => { + this.sftpWrapper!.rmdir(remotePath, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + public async rmdir(remotePath: string): Promise { + const files = await this.list(remotePath); + for (const file of files) { + const fullFilename = utils.joinRemote(this, remotePath, file.name); + if (file.type === "d") { + await this.rmdir(fullFilename); + } else { + await this.unlink(fullFilename); + } + } + await this._rmdir(remotePath); + } + + /** + * Creates a new directory `path`. + */ + public async mkdir(remotePath: string, attributes: InputAttributes = {}): Promise { + utils.haveConnection(this, "mkdir"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.mkdir(remotePath, attributes, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + public async exists(remotePath: string): Promise { + utils.haveConnection(this, "exists"); + try { + const stats = await this.stat(remotePath); + + if (stats.isDirectory()) { + return "d"; + } + if (stats.isSymbolicLink()) { + return "l"; + } + if (stats.isFile()) { + return "-"; + } + return false; + } catch (error) { + return false; + } + } + + /** + * Writes data to a file + */ + public async writeFile(remotePath: string, data: string | Buffer, options: WriteFileOptions = {}): Promise { + utils.haveConnection(this, "writeFile"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.writeFile(remotePath, data, options, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Sets the access time and modified time for `path`. + */ + public async utimes(path: string, atime: number | Date, mtime: number | Date): Promise { + utils.haveConnection(this, "utimes"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.utimes(path, atime, mtime, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Creates a symlink at `linkPath` to `targetPath`. + */ + public async symlink(targetPath: string, linkPath: string): Promise { + utils.haveConnection(this, "symlink"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.symlink(targetPath, linkPath, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Renames/moves `srcPath` to `destPath`. + */ + public async rename(srcPath: string, destPath: string): Promise { + utils.haveConnection(this, "rename"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.rename(srcPath, destPath, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Retrieves the target for a symlink at `path`. + */ + public async readlink(path: string): Promise { + utils.haveConnection(this, "readlink"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.readlink(path, (err, target) => { + if (err) { + reject(err); + } else { + resolve(target); + } + }); + }); + } + + /** + * Reads a file in memory and returns its contents + */ + public async readFile(remotePath: string): Promise { + utils.haveConnection(this, "readFile"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.readFile(remotePath, (err, handle) => { + if (err) { + reject(err); + } else { + resolve(handle); + } + }); + }); + } + + /** + * Retrieves attributes for `path`. If `path` is a symlink, the link itself is stat'ed + * instead of the resource it refers to. + */ + public async lstat(path: string): Promise { + utils.haveConnection(this, "lstat"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.lstat(path, (err, stats) => { + if (err) { + reject(err); + } else { + resolve(stats); + } + }); + }); + } + + /** + * Appends data to a file + */ + public async appendFile(remotePath: string, data: string | Buffer, options: WriteFileOptions): Promise { + utils.haveConnection(this, "appendFile"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.appendFile(remotePath, data, options, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Sets the mode for `path`. + */ + public async chmod(path: string, mode: number | string): Promise { + utils.haveConnection(this, "chmod"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.chmod(path, mode, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Sets the owner for `path`. + */ + public async chown(path: string, uid: number, gid: number): Promise { + utils.haveConnection(this, "chown"); + return new Promise((resolve, reject) => { + this.sftpWrapper!.chown(path, uid, gid, err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Close SSH connection + */ + public close() { + if (this.sshClient && this.sftpWrapper) { + this.sshClient.end(); + this.sshClient = null; + this.sftpWrapper = null; + } + + this.endCalled = true; + } + + /** + * List all files and directories at remotePath + */ + public async list(remotePath: string, pattern = /.*/): Promise { + const _list = (aPath: string, filter: RegExp | string) => { + return new Promise((resolve, reject) => { + const reg = /-/gi; + this.sftpWrapper!.readdir(aPath, (err, fileList) => { + if (err) { + reject(err); + } else { + let newList: any = []; + // reset file info + if (fileList) { + newList = fileList.map(item => { + return { + type: item.longname.substr(0, 1), + name: item.filename, + size: item.attrs.size, + modifyTime: item.attrs.mtime * 1000, + accessTime: item.attrs.atime * 1000, + rights: { + user: item.longname.substr(1, 3).replace(reg, ""), + group: item.longname.substr(4, 3).replace(reg, ""), + other: item.longname.substr(7, 3).replace(reg, ""), + }, + owner: item.attrs.uid, + group: item.attrs.gid, + }; + }); + } + // provide some compatibility for auxList + let regex: RegExp; + if (filter instanceof RegExp) { + regex = filter; + } else { + const newPattern = filter.replace(/\*([^*])*?/gi, ".*"); + regex = new RegExp(newPattern); + } + resolve(newList.filter((item: any) => regex.test(item.name))); + } + }); + }); + }; + + utils.haveConnection(this, "list"); + const pathInfo = await utils.checkRemotePath(this, remotePath, targetType.readDir); + if (!pathInfo.valid) { + throw new Error("Remote path is invalid"); + } + return _list(pathInfo.path, pattern); + } + + /** + * Resolves `path` to an absolute path. + */ + public realPath(remotePath: string): Promise { + return new Promise((resolve, reject) => { + const closeListener = utils.makeCloseListener(this, reject, "realPath"); + this.sshClient!.prependListener("close", closeListener); + const errorListener = utils.makeErrorListener(reject, this, "realPath"); + this.sshClient!.prependListener("error", errorListener); + if (utils.haveConnection(this, "realPath", reject)) { + this.sftpWrapper!.realpath(remotePath, (err, absPath) => { + if (err) { + reject(utils.formatError(`${err.message} ${remotePath}`, "realPath")); + } + resolve(absPath); + this.removeListener("error", errorListener); + this.removeListener("close", closeListener); + }); + } + }); + } +} + +export async function Client(options: TScpOptions): Promise { + const client = new ScpClient(options); + + return new Promise((resolve, reject) => { + client.on("ready", () => { + resolve(client); + }); + + client.on("error", err => { + reject(err); + }); + + client.on("close", () => { + client.removeAllListeners(); + }); + + for (const event in options.events) { + client.on(event, (...args) => { + (options.events![event as keyof ClientEvents] as (...args: any[]) => void)(...args); + }); + } + }); +} + +export default Client; diff --git a/packages/plugins/plugin-lib/src/scp/types.ts b/packages/plugins/plugin-lib/src/scp/types.ts new file mode 100644 index 00000000..bbc69494 --- /dev/null +++ b/packages/plugins/plugin-lib/src/scp/types.ts @@ -0,0 +1,124 @@ +import type { + AcceptConnection, + ChangePasswordCallback, + ClientChannel, + ClientErrorExtensions, + KeyboardInteractiveCallback, + NegotiatedAlgorithms, + ParsedKey, + Prompt, + RejectConnection, + TcpConnectionDetails, + UNIXConnectionDetails, + X11Details, +} from "ssh2"; + +export class ErrorCustom extends Error { + custom?: boolean; + code?: string; + level?: string; + hostname?: string; + address?: string; +} +export interface CheckResult { + path: string; + type?: string; + valid?: boolean; + msg?: string; + code?: string; +} + +export interface ClientEvents { + /** + * Emitted when a notice was sent by the server upon connection. + */ + banner?: (message: string) => void; + + /** + * Emitted when authentication was successful. + */ + ready?: () => void; + + /** + * Emitted when an incoming forwarded TCP connection is being requested. + * + * Calling `accept()` accepts the connection and returns a `Channel` object. + * Calling `reject()` rejects the connection and no further action is needed. + */ + "tcp connection"?: (details: TcpConnectionDetails, accept: AcceptConnection, reject: RejectConnection) => void; + + /** + * Emitted when an incoming X11 connection is being requested. + * + * Calling `accept()` accepts the connection and returns a `Channel` object. + * Calling `reject()` rejects the connection and no further action is needed. + */ + x11?: (details: X11Details, accept: AcceptConnection, reject: RejectConnection) => void; + + /** + * Emitted when the server is asking for replies to the given `prompts` for keyboard- + * interactive user authentication. + * + * * `name` is generally what you'd use as a window title (for GUI apps). + * * `prompts` is an array of `Prompt` objects. + * + * The answers for all prompts must be provided as an array of strings and passed to + * `finish` when you are ready to continue. + * + * NOTE: It's possible for the server to come back and ask more questions. + */ + "keyboard-interactive"?: (name: string, instructions: string, lang: string, prompts: Prompt[], finish: KeyboardInteractiveCallback) => void; + + /** + * Emitted when the server has requested that the user's password be changed, if using + * password-based user authentication. + * + * Call `done` with the new password. + */ + "change password"?: (message: string, done: ChangePasswordCallback) => void; + + /** + * Emitted when an error occurred. + */ + error?: (err: Error & ClientErrorExtensions) => void; + + /** + * Emitted when the socket was disconnected. + */ + end?: () => void; + + /** + * Emitted when the socket was closed. + */ + close?: () => void; + + /** + * Emitted when the socket has timed out. + */ + timeout?: () => void; + + /** + * Emitted when the socket has connected. + */ + connect?: () => void; + + /** + * Emitted when the server responds with a greeting message. + */ + greeting?: (greeting: string) => void; + + /** + * Emitted when a handshake has completed (either initial or rekey). + */ + handshake?: (negotiated: NegotiatedAlgorithms) => void; + + /** + * Emitted when the server announces its available host keys. + */ + hostkeys?: (keys: ParsedKey[]) => void; + + /** + * An incoming forwarded UNIX socket connection is being requested. + */ + "unix connection"?: (info: UNIXConnectionDetails, accept: AcceptConnection, reject: RejectConnection) => void; +} diff --git a/packages/plugins/plugin-lib/src/scp/utils.ts b/packages/plugins/plugin-lib/src/scp/utils.ts new file mode 100644 index 00000000..6e54f4bc --- /dev/null +++ b/packages/plugins/plugin-lib/src/scp/utils.ts @@ -0,0 +1,639 @@ +import fs from "fs"; +import path from "path"; +import { errorCode, targetType } from "./constant.js"; +import { EventEmitter } from "events"; +import { ScpClient } from "."; +import { CheckResult, ErrorCustom } from "./types.js"; + +/** + * Generate a new Error object with a reformatted error message which + * is a little more informative and useful to users. + * + * @param {Error|string} err - The Error object the new error will be based on + * @param {number} retryCount - For those functions which use retry. Number of + * attempts to complete before giving up + * @returns {Error} New error with custom error message + */ +export function formatError(err: ErrorCustom | string, name = "sftp", eCode = errorCode.generic, retryCount?: number) { + let msg = ""; + let code = ""; + const retry = retryCount ? ` after ${retryCount} ${retryCount > 1 ? "attempts" : "attempt"}` : ""; + + if (err === undefined) { + msg = `${name}: Undefined error - probably a bug!`; + code = errorCode.generic; + } else if (typeof err === "string") { + msg = `${name}: ${err}${retry}`; + code = eCode; + } else if (err.custom) { + msg = `${name}->${err.message}${retry}`; + code = err.code!; + } else { + switch (err.code) { + case "ENOTFOUND": + msg = `${name}: ${err.level} error. ` + `Address lookup failed for host ${err.hostname}${retry}`; + break; + case "ECONNREFUSED": + msg = `${name}: ${err.level} error. Remote host at ` + `${err.address} refused connection${retry}`; + break; + case "ECONNRESET": + msg = `${name}: Remote host has reset the connection: ` + `${err.message}${retry}`; + break; + case "ENOENT": + msg = `${name}: ${err.message}${retry}`; + break; + default: + msg = `${name}: ${err.message}${retry}`; + } + code = err.code ? err.code : eCode; + } + const newError = new ErrorCustom(msg); + newError.code = code; + newError.custom = true; + return newError; +} + +/** + * Tests an error to see if it is one which has already been customised + * by this module or not. If not, applies appropriate customisation. + * + * @param {Error} err - an Error object + * @param {String} name - name to be used in customised error message + * @param {Function} reject - If defined, call this function instead of + * throwing the error + * @throws {Error} + */ +export function handleError(err: ErrorCustom, name: string, reject: (e: any) => void) { + if (reject) { + if (err.custom) { + reject(err); + } else { + reject(formatError(err, name, undefined, undefined)); + } + } else { + if (err.custom) { + throw err; + } else { + throw formatError(err, name, undefined, undefined); + } + } +} + +/** + * Remove all ready, error and end listeners. + * + * @param {Emitter} emitter - The emitter object to remove listeners from + */ +// function removeListeners(emitter) { +// const listeners = emitter.eventNames() +// listeners.forEach((name) => { +// emitter.removeAllListeners(name) +// }) +// } + +/** + * Simple default error listener. Will reformat the error message and + * throw a new error. + * + * @param {Error} err - source for defining new error + * @throws {Error} Throws new error + */ +export function makeErrorListener(reject: (e: any) => void, client: ScpClient, name: string) { + return (err: Error) => { + client.errorHandled = true; + reject(formatError(err, name)); + }; +} + +export function makeEndListener(client: ScpClient) { + return () => { + if (!client.endCalled) { + console.error("End Listener: Connection ended unexpectedly"); + } + }; +} + +export function makeCloseListener(client: ScpClient, reject?: (e: any) => void, name?: string) { + return () => { + if (!client.endCalled) { + if (reject) { + reject(formatError("Connection closed unexpectedly", name)); + } else { + console.error("Connection closed unexpectedly"); + } + } + client.sftpWrapper = null; + }; +} + +/** + * @async + * + * Tests to see if a path identifies an existing item. Returns either + * 'd' = directory, 'l' = sym link or '-' regular file if item exists. Returns + * false if it does not + * + * @param {String} localPath + * @returns {Boolean | String} + */ +export function localExists(localPath: string): Promise { + return new Promise((resolve, reject) => { + fs.stat(localPath, (err, stats) => { + if (err) { + if (err.code === "ENOENT") { + resolve("ENOENT"); + } else { + reject(err); + } + } else { + if (stats.isDirectory()) { + resolve("d"); + } else if (stats.isSymbolicLink()) { + resolve("l"); + } else if (stats.isFile()) { + resolve("-"); + } else { + resolve(""); + } + } + }); + }); +} + +/** + * Used by checkRemotePath and checkLocalPath to help ensure consistent + * error messages. + * + * @param {Error} err - original error + * @param {String} testPath - path associated with the error + * @returns {Object} with properties of 'msg' and 'code'. + */ +export function classifyError(err: ErrorCustom, testPath: string) { + switch (err.code) { + case "EACCES": + return { + msg: `Permission denied: ${testPath}`, + code: errorCode.permission, + }; + case "ENOENT": + return { + msg: `No such file: ${testPath}`, + code: errorCode.notexist, + }; + case "ENOTDIR": + return { + msg: `Not a directory: ${testPath}`, + code: errorCode.notdir, + }; + default: + return { + msg: err.message, + code: err.code ? err.code : errorCode.generic, + }; + } +} + +export function localAccess(localPath: string, mode: number): Promise { + return new Promise(resolve => { + fs.access(localPath, mode, err => { + if (err) { + const { msg, code } = classifyError(err, localPath); + resolve({ + path: localPath, + valid: false, + msg, + code, + }); + } else { + resolve({ + path: localPath, + valid: true, + }); + } + }); + }); +} + +export async function checkLocalReadFile(localPath: string, localType: string) { + try { + const rslt: CheckResult = { + path: localPath, + type: localType, + }; + if (localType === "d") { + rslt.valid = false; + rslt.msg = `Bad path: ${localPath} must be a file`; + rslt.code = errorCode.badPath; + return rslt; + } else { + const access = await localAccess(localPath, fs.constants.R_OK); + if (access.valid) { + rslt.valid = true; + return rslt; + } else { + rslt.valid = false; + rslt.msg = access.msg; + rslt.code = access.code; + return rslt; + } + } + } catch (err) { + throw formatError(err as ErrorCustom, "checkLocalReadFile"); + } +} + +export async function checkLocalReadDir(localPath: string, localType: string) { + try { + const rslt: CheckResult = { + path: localPath, + type: localType, + }; + if (!localType) { + rslt.valid = false; + rslt.msg = `No such directory: ${localPath}`; + rslt.code = errorCode.notdir; + return rslt; + } else if (localType !== "d") { + rslt.valid = false; + rslt.msg = `Bad path: ${localPath} must be a directory`; + rslt.code = errorCode.badPath; + return rslt; + } else { + const access = await localAccess(localPath, fs.constants.R_OK | fs.constants.X_OK); + if (!access.valid) { + rslt.valid = false; + rslt.msg = access.msg; + rslt.code = access.code; + return rslt; + } + rslt.valid = true; + return rslt; + } + } catch (err) { + throw formatError(err as ErrorCustom, "checkLocalReadDir"); + } +} + +export async function checkLocalWriteFile(localPath: string, localType: string) { + try { + const rslt: CheckResult = { + path: localPath, + type: localType, + }; + if (localType === "d") { + rslt.valid = false; + rslt.msg = `Bad path: ${localPath} must be a file`; + rslt.code = errorCode.badPath; + return rslt; + } else if (!localType) { + const dir = path.parse(localPath).dir; + const parent = await localAccess(dir, fs.constants.W_OK); + if (parent.valid) { + rslt.valid = true; + return rslt; + } else { + rslt.valid = false; + rslt.msg = parent.msg; + rslt.code = parent.code; + return rslt; + } + } else { + const access = await localAccess(localPath, fs.constants.W_OK); + if (access.valid) { + rslt.valid = true; + return rslt; + } else { + rslt.valid = false; + rslt.msg = access.msg; + rslt.code = access.code; + return rslt; + } + } + } catch (err) { + throw formatError(err as ErrorCustom, "checkLocalWriteFile"); + } +} + +export async function checkLocalWriteDir(localPath: string, localType: string) { + try { + const rslt: CheckResult = { + path: localPath, + type: localType, + }; + if (!localType) { + const parent = path.parse(localPath).dir; + const access = await localAccess(parent, fs.constants.W_OK); + if (access.valid) { + rslt.valid = true; + return rslt; + } else { + rslt.valid = false; + rslt.msg = access.msg; + rslt.code = access.code; + return rslt; + } + } else if (localType !== "d") { + rslt.valid = false; + rslt.msg = `Bad path: ${localPath} must be a directory`; + rslt.code = errorCode.badPath; + return rslt; + } else { + const access = await localAccess(localPath, fs.constants.W_OK); + if (access.valid) { + rslt.valid = true; + return rslt; + } else { + rslt.valid = false; + rslt.msg = access.msg; + rslt.code = access.code; + return rslt; + } + } + } catch (err) { + throw formatError(err as ErrorCustom, "checkLocalWriteDir"); + } +} + +export async function checkLocalPath(lPath: string, target = targetType.readFile) { + const localPath = path.resolve(lPath); + const type = await localExists(localPath); + switch (target) { + case targetType.readFile: + return checkLocalReadFile(localPath, type); + case targetType.readDir: + return checkLocalReadDir(localPath, type); + case targetType.writeFile: + return checkLocalWriteFile(localPath, type); + case targetType.writeDir: + return checkLocalWriteDir(localPath, type); + default: + return { + path: localPath, + type, + valid: true, + }; + } +} + +export async function normalizeRemotePath(client: ScpClient, aPath: string) { + try { + if (aPath.startsWith("..")) { + const root = await client.realPath(".."); + return root + client.remotePathSep + aPath.substring(3); + } else if (aPath.startsWith(".")) { + const root = await client.realPath("."); + return root + client.remotePathSep + aPath.substring(2); + } + return aPath; + } catch (err) { + throw formatError(err as ErrorCustom, "normalizeRemotePath"); + } +} + +export function checkReadObject(aPath: string, type: string) { + return { + path: aPath, + type, + valid: type ? true : false, + msg: type ? undefined : `No such file ${aPath}`, + code: type ? undefined : errorCode.notexist, + }; +} + +export function checkReadFile(aPath: string, type: string) { + if (!type) { + return { + path: aPath, + type, + valid: false, + msg: `No such file: ${aPath}`, + code: errorCode.notexist, + }; + } else if (type === "d") { + return { + path: aPath, + type, + valid: false, + msg: `Bad path: ${aPath} must be a file`, + code: errorCode.badPath, + }; + } + return { + path: aPath, + type, + valid: true, + }; +} + +export function checkReadDir(aPath: string, type: string) { + if (!type) { + return { + path: aPath, + type, + valid: false, + msg: `No such directory: ${aPath}`, + code: errorCode.notdir, + }; + } else if (type !== "d") { + return { + path: aPath, + type, + valid: false, + msg: `Bad path: ${aPath} must be a directory`, + code: errorCode.badPath, + }; + } + return { + path: aPath, + type, + valid: true, + }; +} + +export async function checkWriteFile(client: ScpClient, aPath: string, type: string) { + if (type && type === "d") { + return { + path: aPath, + type, + valid: false, + msg: `Bad path: ${aPath} must be a regular file`, + code: errorCode.badPath, + }; + } else if (!type) { + const { root, dir } = path.parse(aPath); + // let parentDir = path.parse(aPath).dir; + if (!dir) { + return { + path: aPath, + type: false, + valid: false, + msg: `Bad path: ${aPath} cannot determine parent directory`, + code: errorCode.badPath, + }; + } + if (root === dir) { + return { + path: aPath, + type, + valid: true, + }; + } + const parentType = await client.exists(dir); + if (!parentType) { + return { + path: aPath, + type, + valid: false, + msg: `Bad path: ${dir} parent not exist`, + code: errorCode.badPath, + }; + } else if (parentType !== "d") { + return { + path: aPath, + type, + valid: false, + msg: `Bad path: ${dir} must be a directory`, + code: errorCode.badPath, + }; + } + return { + path: aPath, + type, + valid: true, + }; + } + return { + path: aPath, + type, + valid: true, + }; +} + +export async function checkWriteDir(client: ScpClient, aPath: string, type: string) { + if (type && type !== "d") { + return { + path: aPath, + type, + valid: false, + msg: `Bad path: ${aPath} must be a directory`, + code: errorCode.badPath, + }; + } else if (!type) { + const { root, dir } = path.parse(aPath); + if (root === dir) { + return { + path: aPath, + type, + valid: true, + }; + } + if (!dir) { + return { + path: aPath, + type: false, + valid: false, + msg: `Bad path: ${aPath} cannot determine directory parent`, + code: errorCode.badPath, + }; + } + const parentType = await client.exists(dir); + if (parentType && parentType !== "d") { + return { + path: aPath, + type, + valid: false, + msg: "Bad path: Parent Directory must be a directory", + code: errorCode.badPath, + }; + } + } + // don't care if parent does not exist as it might be created + // via recursive call to mkdir. + return { + path: aPath, + type, + valid: true, + }; +} + +export function checkWriteObject(aPath: string, type: string) { + // for writeObj, not much error checking we can do + // Just return path, type and valid indicator + return { + path: aPath, + type, + valid: true, + }; +} + +export async function checkRemotePath(client: ScpClient, rPath: string, target = targetType.readFile) { + const aPath = await normalizeRemotePath(client, rPath); + const type = await client.exists(aPath); + switch (target) { + case targetType.readObj: + return checkReadObject(aPath, type as string); + case targetType.readFile: + return checkReadFile(aPath, type as string); + case targetType.readDir: + return checkReadDir(aPath, type as string); + case targetType.writeFile: + return checkWriteFile(client, aPath, type as string); + case targetType.writeDir: + return checkWriteDir(client, aPath, type as string); + case targetType.writeObj: + return checkWriteObject(aPath, type as string); + default: + throw formatError(`Unknown target type: ${target}`, "checkRemotePath", errorCode.generic); + } +} + +/** + * Check to see if there is an active sftp connection + * + * @param {Object} client - current sftp object + * @param {String} name - name given to this connection + * @param {Function} reject - if defined, call this rather than throw + * an error + * @returns {Boolean} True if connection OK + * @throws {Error} + */ +export function haveConnection(client: ScpClient, name: string, reject?: (e: any) => void) { + if (!client.sftpWrapper) { + const newError = formatError("No SFTP connection available", name, errorCode.connect); + if (reject) { + reject(newError); + return false; + } else { + throw newError; + } + } + return true; +} + +export function dumpListeners(emitter: EventEmitter) { + const eventNames = emitter.eventNames(); + if (eventNames.length) { + console.log("Listener Data"); + eventNames.map((n: any) => { + const listeners = emitter.listeners(n); + console.log(`${n}: ${emitter.listenerCount(n)}`); + console.dir(listeners); + listeners.map(l => { + console.log(`listener name = ${l.name}`); + }); + }); + } +} + +export function hasListener(emitter: EventEmitter, eventName: string, listenerName: string) { + const listeners = emitter.listeners(eventName); + const matches = listeners.filter(l => l.name === listenerName); + return matches.length === 0 ? false : true; +} + +export function joinRemote(client: ScpClient, ...args: string[]) { + if (client.remotePathSep === path.win32.sep) { + return path.win32.join(...args); + } + return path.posix.join(...args); +} diff --git a/packages/plugins/plugin-lib/src/ssh/ssh.ts b/packages/plugins/plugin-lib/src/ssh/ssh.ts index 7df2c67d..338c08a1 100644 --- a/packages/plugins/plugin-lib/src/ssh/ssh.ts +++ b/packages/plugins/plugin-lib/src/ssh/ssh.ts @@ -7,6 +7,8 @@ import { SshAccess } from "./ssh-access.js"; import stripAnsi from "strip-ansi"; import { SocksClient } from "socks"; import { SocksProxy, SocksProxyType } from "socks/typings/common/constants.js"; +import { ScpClient } from "../scp/index.js"; +import fs from "fs"; export type TransportItem = { localPath: string; remotePath: string }; export class AsyncSsh2Client { @@ -265,15 +267,15 @@ export class SshClient { } * @param options */ - async uploadFiles(options: { connectConf: SshAccess; transports: TransportItem[]; mkdirs: boolean; opts?: { mode?: string } }) { + async uploadFiles(options: { connectConf: SshAccess; transports: TransportItem[]; mkdirs: boolean; opts?: { mode?: string }; uploadType?: string }) { const { connectConf, transports, mkdirs, opts } = options; await this._call({ connectConf, callable: async (conn: AsyncSsh2Client) => { - const sftp = await conn.getSftp(); this.logger.info("开始上传"); - for (const transport of transports) { - if (mkdirs !== false) { + if (mkdirs !== false) { + this.logger.info("初始化父目录"); + for (const transport of transports) { const filePath = path.dirname(transport.remotePath); let mkdirCmd = `mkdir -p ${filePath} `; if (conn.windows) { @@ -291,13 +293,60 @@ export class SshClient { } await conn.exec(mkdirCmd); } - await conn.fastPut({ sftp, ...transport, opts }); } + + if (options.uploadType === "sftp") { + const sftp = await conn.getSftp(); + for (const transport of transports) { + await conn.fastPut({ sftp, ...transport, opts }); + } + } else { + //scp + for (const transport of transports) { + await this.scpUpload({ conn, ...transport, opts }); + } + } + this.logger.info("文件全部上传成功"); }, }); } + async scpUpload(options: { conn: any; localPath: string; remotePath: string; opts?: { mode?: string } }) { + const { conn, localPath, remotePath, opts } = options; + return new Promise((resolve, reject) => { + // 关键步骤:构造 SCP 命令 + try { + this.logger.info(`开始上传:${localPath} => ${remotePath}`); + conn.conn.exec( + `scp -t ${remotePath}`, // -t 表示目标模式 + (err, stream) => { + if (err) { + return reject(err); + } + // 准备 SCP 协议头 + const fileStats = fs.statSync(localPath); + const fileName = path.basename(localPath); + + // SCP 协议格式:C[权限] [文件大小] [文件名]\n + stream.write(`C0644 ${fileStats.size} ${fileName}\n`); + + // 通过管道传输文件 + fs.createReadStream(localPath) + .pipe(stream) + .on("finish", () => { + this.logger.info(`上传文件成功:${localPath} => ${remotePath}`); + resolve(true); + }) + .on("error", reject); + } + ); + } catch (e) { + reject(e); + } + }); + } + async removeFiles(opts: { connectConf: SshAccess; files: string[] }) { const { connectConf, files } = opts; await this._call({ diff --git a/packages/ui/certd-server/src/plugins/plugin-host/plugin/upload-to-host/index.ts b/packages/ui/certd-server/src/plugins/plugin-host/plugin/upload-to-host/index.ts index 74ed2b4d..0c3c2108 100644 --- a/packages/ui/certd-server/src/plugins/plugin-host/plugin/upload-to-host/index.ts +++ b/packages/ui/certd-server/src/plugins/plugin-host/plugin/upload-to-host/index.ts @@ -179,6 +179,21 @@ export class UploadCertToHostPlugin extends AbstractTaskPlugin { }) accessId!: string; + @TaskInput({ + title: '上传方式', + helper: '选择上传方式,sftp或者scp', + value:"sftp", + component: { + name: 'a-select', + options: [ + { value: 'sftp', label: 'sftp' }, + { value: 'scp', label: 'scp' }, + ], + }, + required: true, + }) + uploadType: string = 'sftp'; + @TaskInput({ title: '自动创建远程目录', helper: '是否自动创建远程目录,如果关闭则你需要自己确保远程目录存在', @@ -249,18 +264,7 @@ export class UploadCertToHostPlugin extends AbstractTaskPlugin { async onInstance() {} - // copyFile(srcFile: string, destFile: string) { - // if (!srcFile || !destFile) { - // this.logger.warn(`srcFile:${srcFile} 或 destFile:${destFile} 为空,不复制`); - // return; - // } - // const dir = destFile.substring(0, destFile.lastIndexOf('/')); - // if (!fs.existsSync(dir)) { - // fs.mkdirSync(dir, { recursive: true }); - // } - // fs.copyFileSync(srcFile, destFile); - // this.logger.info(`复制文件:${srcFile} => ${destFile}`); - // } + async execute(): Promise { const { cert, accessId } = this; let { crtPath, keyPath, icPath, pfxPath, derPath, jksPath, onePath } = this; @@ -268,16 +272,6 @@ export class UploadCertToHostPlugin extends AbstractTaskPlugin { const handle = async (opts: CertReaderHandleContext) => { const { tmpCrtPath, tmpKeyPath, tmpDerPath, tmpJksPath, tmpPfxPath, tmpIcPath, tmpOnePath } = opts; - // if (this.copyToThisHost) { - // this.logger.info('复制到目标路径'); - // this.copyFile(tmpCrtPath, crtPath); - // this.copyFile(tmpKeyPath, keyPath); - // this.copyFile(tmpIcPath, this.icPath); - // this.copyFile(tmpPfxPath, this.pfxPath); - // this.copyFile(tmpDerPath, this.derPath); - // this.logger.warn('复制到当前主机功能已迁移到 “复制到本机”插件,请尽快换成复制到本机插件'); - // return; - // } if (accessId == null) { this.logger.error('复制到当前主机功能已迁移到 “复制到本机”插件,请换成复制到本机插件'); @@ -355,7 +349,9 @@ export class UploadCertToHostPlugin extends AbstractTaskPlugin { connectConf, transports, mkdirs: this.mkdirs, + uploadType: this.uploadType, }); + this.logger.info('上传文件到服务器成功'); //输出 this.hostCrtPath = crtPath;