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;