diff --git a/kit/package-lock.json b/kit/package-lock.json index 04dea82..cb42952 100644 --- a/kit/package-lock.json +++ b/kit/package-lock.json @@ -18,6 +18,7 @@ "node-forge": "^1.3.1", "physical-cpu-count": "^2.0.0", "sm-crypto": "^0.3.13", + "ssh2": "^1.16.0", "systeminformation": "^5.30.6", "webdav": "^5.3.1" }, @@ -56,6 +57,15 @@ "resolved": "https://registry.npmmirror.com/applescript/-/applescript-1.0.0.tgz", "integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ==" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/auto-launch": { "version": "5.0.6", "resolved": "https://registry.npmmirror.com/auto-launch/-/auto-launch-5.0.6.tgz", @@ -81,6 +91,15 @@ "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -97,6 +116,15 @@ "node": "*" } }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", @@ -110,6 +138,20 @@ "node": "*" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-port-killer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/cross-port-killer/-/cross-port-killer-1.4.0.tgz", @@ -417,6 +459,13 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, "node_modules/nested-property": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", @@ -523,6 +572,12 @@ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sm-crypto": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/sm-crypto/-/sm-crypto-0.3.13.tgz", @@ -531,6 +586,23 @@ "jsbn": "^1.1.0" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/streamroller": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", @@ -575,6 +647,12 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/kit/package.json b/kit/package.json index b159dab..463e6a4 100644 --- a/kit/package.json +++ b/kit/package.json @@ -22,6 +22,7 @@ "node-forge": "^1.3.1", "physical-cpu-count": "^2.0.0", "sm-crypto": "^0.3.13", + "ssh2": "^1.16.0", "systeminformation": "^5.30.6", "webdav": "^5.3.1" } diff --git a/kit/src/kitfunction.js b/kit/src/kitfunction.js index 23420d4..af3c917 100644 --- a/kit/src/kitfunction.js +++ b/kit/src/kitfunction.js @@ -335,12 +335,155 @@ function scp(from, to, params = {}) { })); } +function loadSsh2Client() { + try { + return require("ssh2").Client; + } catch (e) { + throw new Error("remoteShell requires pure JS dependency ssh2. Please run npm install ssh2 in kit project."); + } +} + +function normalizeRemoteShellConfig(config = {}) { + const host = config.host || config.hostname; + if (!host) { + throw new Error("remoteShell host is required"); + } + + const username = config.username || config.user; + if (!username) { + throw new Error("remoteShell username is required"); + } + + const password = resolveScpPassword(config); + const privateKey = config.privateKey || config.keyText || (config.keyFile ? fs.readFileSync(path.resolve(config.keyFile), "utf-8") : undefined); + const connectConfig = Object.assign({}, config, { + host, + username, + port: config.port || 22, + password, + privateKey, + passphrase: resolveValue(config.passphrase) + }); + + delete connectConfig.user; + delete connectConfig.hostname; + delete connectConfig.pwd; + delete connectConfig.plainPwd; + delete connectConfig.plainPassword; + delete connectConfig.plaintextPwd; + delete connectConfig.plaintextPassword; + delete connectConfig.keyText; + delete connectConfig.keyFile; + + return connectConfig; +} + +function remoteShell(config = {}) { + const Client = loadSsh2Client(); + const connectConfig = normalizeRemoteShellConfig(config); + let client = null; + let connectPromise = null; + + function connect() { + if (client) { + return Promise.resolve(client); + } + if (connectPromise) { + return connectPromise; + } + + connectPromise = new Promise((resolve, reject) => { + const conn = new Client(); + conn.once("ready", () => { + client = conn; + resolve(conn); + }); + conn.once("error", (e) => { + connectPromise = null; + reject(e); + }); + conn.once("close", () => { + client = null; + connectPromise = null; + }); + conn.connect(connectConfig); + }); + return connectPromise; + } + + async function exec(cmd, options = {}) { + if (!cmd) { + throw new Error("remoteShell exec cmd is required"); + } + + const conn = await connect(); + return new Promise((resolve, reject) => { + const execOptions = Object.assign({}, options); + delete execOptions.stdio; + delete execOptions.rejectOnError; + conn.exec(cmd, execOptions, (err, stream) => { + if (err) { + reject(err); + return; + } + + let stdout = ""; + let stderr = ""; + stream.on("error", reject); + stream.on("close", (code, signal) => { + const result = { code, signal, stdout, stderr }; + if (options.rejectOnError === false || code === 0) { + resolve(result); + } else { + const error = new Error(`Remote command failed with code ${code}: ${cmd}`); + error.result = result; + reject(error); + } + }); + stream.on("data", (data) => { + const text = data.toString(); + stdout += text; + if (options.stdio === "inherit") { + process.stdout.write(text); + } + }); + stream.stderr.on("data", (data) => { + const text = data.toString(); + stderr += text; + if (options.stdio === "inherit") { + process.stderr.write(text); + } + }); + }); + }); + } + + async function execText(cmd, options = {}) { + const result = await exec(cmd, options); + return result.stdout; + } + + async function call(cmd, options = {}) { + return exec(cmd, Object.assign({ stdio: "inherit" }, options)); + } + + function close() { + if (client) { + client.end(); + } + client = null; + connectPromise = null; + } + + return { connect, exec, execText, call, close }; +} + function exit(code = 0) { process.exit(code); } function isSecretLogKey(key) { - return /^(pwd|password|plainPwd|plainPassword|plaintextPwd|plaintextPassword)$/i.test(key); + return /^(pwd|password|plainPwd|plainPassword|plaintextPwd|plaintextPassword|privateKey|passphrase|keyText)$/i.test(key); } function redactLogValue(value) { @@ -403,6 +546,7 @@ module.exports = { readJson: withCallLog("readJson", readJson), writeJson: withCallLog("writeJson", writeJson), scp: withCallLog("scp", scp), + remoteShell: withCallLog("remoteShell", remoteShell), exit: withCallLog("exit", exit), setVar: withCallLog("setVar", setVar), getVar: withCallLog("getVar", getVar),