From 383a3449b77305a162571e1bcb0299a1f86c2be2 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sat, 29 May 2021 00:45:59 -0400 Subject: [PATCH] :sparkles: Add SSH Node (#1837) * :sparkles: SSH-Node * :zap: Fix issue * :zap: Add file resource * :zap: Improvements * :zap: Some improvements Co-authored-by: Jan Oberhauser --- .../credentials/SshPassword.credentials.ts | 41 ++ .../credentials/SshPrivateKey.credentials.ts | 49 ++ packages/nodes-base/nodes/Ssh/Ssh.node.ts | 421 ++++++++++++++++++ packages/nodes-base/package.json | 6 + 4 files changed, 517 insertions(+) create mode 100644 packages/nodes-base/credentials/SshPassword.credentials.ts create mode 100644 packages/nodes-base/credentials/SshPrivateKey.credentials.ts create mode 100644 packages/nodes-base/nodes/Ssh/Ssh.node.ts diff --git a/packages/nodes-base/credentials/SshPassword.credentials.ts b/packages/nodes-base/credentials/SshPassword.credentials.ts new file mode 100644 index 0000000000..36f9118464 --- /dev/null +++ b/packages/nodes-base/credentials/SshPassword.credentials.ts @@ -0,0 +1,41 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SshPassword implements ICredentialType { + name = 'sshPassword'; + displayName = 'SSH'; + properties = [ + { + displayName: 'Host', + name: 'host', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'localhost', + }, + { + displayName: 'Port', + name: 'port', + required: true, + type: 'number' as NodePropertyTypes, + default: 22, + }, + { + displayName: 'Username', + name: 'username', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/SshPrivateKey.credentials.ts b/packages/nodes-base/credentials/SshPrivateKey.credentials.ts new file mode 100644 index 0000000000..773046d2e2 --- /dev/null +++ b/packages/nodes-base/credentials/SshPrivateKey.credentials.ts @@ -0,0 +1,49 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SshPrivateKey implements ICredentialType { + name = 'sshPrivateKey'; + displayName = 'SSH'; + properties = [ + { + displayName: 'Host', + name: 'host', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'localhost', + }, + { + displayName: 'Port', + name: 'port', + required: true, + type: 'number' as NodePropertyTypes, + default: 22, + }, + { + displayName: 'Username', + name: 'username', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Private Key', + name: 'privateKey', + type: 'string' as NodePropertyTypes, + typeOptions: { + rows: 4, + }, + default: '', + }, + { + displayName: 'Passphrase', + name: 'passphrase', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Passphase used to create the key, if no passphase was used leave empty', + }, + + ]; +} diff --git a/packages/nodes-base/nodes/Ssh/Ssh.node.ts b/packages/nodes-base/nodes/Ssh/Ssh.node.ts new file mode 100644 index 0000000000..18759f869a --- /dev/null +++ b/packages/nodes-base/nodes/Ssh/Ssh.node.ts @@ -0,0 +1,421 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryData, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + readFile, + rm, + writeFile, +} from 'fs/promises' + +import { file } from 'tmp-promise'; + +const nodeSSH = require('node-ssh'); + +export class Ssh implements INodeType { + description: INodeTypeDescription = { + displayName: 'SSH', + name: 'Ssh', + icon: 'fa:terminal', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Execute commands via SSH', + defaults: { + name: 'SSH', + color: '#000000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'sshPassword', + required: true, + displayOptions: { + show: { + authentication: [ + 'password', + ], + }, + }, + }, + { + name: 'sshPrivateKey', + required: true, + displayOptions: { + show: { + authentication: [ + 'privateKey', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Password', + value: 'password', + }, + { + name: 'Private Key', + value: 'privateKey', + }, + ], + default: 'password', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Command', + value: 'command', + }, + { + name: 'File', + value: 'file', + }, + ], + default: 'command', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'command', + ], + }, + }, + options: [ + { + name: 'Execute', + value: 'execute', + description: 'Execute a command', + }, + ], + default: 'execute', + description: 'Operation to perform.', + }, + { + displayName: 'Command', + name: 'command', + type: 'string', + displayOptions: { + show: { + resource: [ + 'command', + ], + operation: [ + 'execute', + ], + }, + }, + default: '', + description: 'The command to be executed on a remote device.', + }, + { + displayName: 'Working Directory', + name: 'cwd', + type: 'string', + displayOptions: { + show: { + resource: [ + 'command', + ], + operation: [ + 'execute', + ], + }, + }, + default: '/', + required: true, + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'file', + ], + }, + }, + options: [ + { + name: 'Download', + value: 'download', + description: 'Download a file', + }, + { + name: 'Upload', + value: 'upload', + description: 'Upload a file', + }, + ], + default: 'upload', + description: 'Operation to perform.', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + operation: [ + 'upload', + ], + resource: [ + 'file', + ], + }, + }, + placeholder: '', + description: 'Name of the binary property which contains
the data for the file to be uploaded.', + }, + { + displayName: 'Target Directory', + name: 'path', + type: 'string', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + }, + }, + default: '', + required: true, + placeholder: '/home/user', + description: `The directory to upload the file to. The name of the file does not need to be specified,
+ it's taken from the binary data file name. To override this behavior, set the parameter
+ "File Name" under options.`, + }, + { + displayName: 'Path', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'download', + ], + }, + }, + name: 'path', + type: 'string', + default: '', + placeholder: '/home/user/invoice.txt', + description: 'The file path of the file to download. Has to contain the full path including file name.', + required: true, + }, + { + displayName: 'Binary Property', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'download', + ], + }, + }, + name: 'binaryPropertyName', + type: 'string', + default: 'data', + description: 'Object property name which holds binary data.', + required: true, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + description: `Overrides the binary data file name.`, + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const returnData: IDataObject[] = []; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const authentication = this.getNodeParameter('authentication', 0) as string; + + const cleanupFiles: string[] = []; + + const ssh = new nodeSSH.NodeSSH(); + + try { + if (authentication === 'password') { + + const credentials = this.getCredentials('sshPassword') as IDataObject; + + await ssh.connect({ + host: credentials.host as string, + username: credentials.username as string, + port: credentials.port as number, + password: credentials.password as string, + }); + + } else if (authentication === 'privateKey') { + + const credentials = this.getCredentials('sshPrivateKey') as IDataObject; + + const { path, } = await file(); + cleanupFiles.push(path); + await writeFile(path, credentials.privateKey as string); + + const options = { + host: credentials.host as string, + username: credentials.username as string, + port: credentials.port as number, + privateKey: path, + } as any; // tslint:disable-line: no-any + + if (!credentials.passphrase) { + options.passphrase = credentials.passphrase as string; + } + + await ssh.connect(options); + } + + for (let i = 0; i < items.length; i++) { + + if (resource === 'command') { + + if (operation === 'execute') { + + const command = this.getNodeParameter('command', i) as string; + const cwd = this.getNodeParameter('cwd', i) as string; + returnData.push(await ssh.execCommand(command, { cwd, })); + } + } + + if (resource === 'file') { + + if (operation === 'download') { + + const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i) as string; + const parameterPath = this.getNodeParameter('path', i) as string; + + const { path } = await file({mode: 0x0777, prefix: 'prefix-'}); + cleanupFiles.push(path); + + await ssh.getFile(path, parameterPath); + + const newItem: INodeExecutionData = { + json: items[i].json, + binary: {}, + }; + + if (items[i].binary !== undefined) { + // Create a shallow copy of the binary data so that the old + // data references which do not get changed still stay behind + // but the incoming data does not get changed. + Object.assign(newItem.binary, items[i].binary); + } + + items[i] = newItem; + + const data = await readFile(path as string); + + items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data, parameterPath); + } + + if (operation === 'upload') { + + const parameterPath = this.getNodeParameter('path', i) as string; + const fileName = this.getNodeParameter('options.fileName', i, '') as string; + + console.log('path', parameterPath); + + const item = items[i]; + + if (item.binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + const propertyNameUpload = this.getNodeParameter('binaryPropertyName', i) as string; + + const binaryData = item.binary[propertyNameUpload] as IBinaryData; + + if (item.binary[propertyNameUpload] === undefined) { + throw new Error(`No binary data property "${propertyNameUpload}" does not exists on item!`); + } + + const { fd, path } = await file(); + cleanupFiles.push(path); + await fsWriteFileAsync(fd, Buffer.from(binaryData.data, BINARY_ENCODING)); + + await ssh.putFile(path, `${parameterPath}${(parameterPath.charAt(parameterPath.length -1) === '/') ? '' : '/'}${fileName || binaryData.fileName}`); + + returnData.push({ success: true }); + } + } + } + } catch (error) { + ssh.dispose(); + for (const cleanup of cleanupFiles) await rm(cleanup); + throw error; + } + + for (const cleanup of cleanupFiles) await rm(cleanup); + + ssh.dispose(); + + if (resource === 'file' && operation === 'download') { + // For file downloads the files get attached to the existing items + return this.prepareOutputData(items); + } else { + return [this.helpers.returnJsonArray(returnData)]; + } + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 16c050f145..9c1a149ce6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -225,6 +225,10 @@ "dist/credentials/StackbyApi.credentials.js", "dist/credentials/StravaOAuth2Api.credentials.js", "dist/credentials/StripeApi.credentials.js", + "dist/credentials/SalesmateApi.credentials.js", + "dist/credentials/SegmentApi.credentials.js", + "dist/credentials/SshPassword.credentials.js", + "dist/credentials/SshPrivateKey.credentials.js", "dist/credentials/Sftp.credentials.js", "dist/credentials/Signl4Api.credentials.js", "dist/credentials/SpontitApi.credentials.js", @@ -508,6 +512,7 @@ "dist/nodes/SpreadsheetFile.node.js", "dist/nodes/Stackby/Stackby.node.js", "dist/nodes/SseTrigger.node.js", + "dist/nodes/Ssh/Ssh.node.js", "dist/nodes/Start.node.js", "dist/nodes/Storyblok/Storyblok.node.js", "dist/nodes/Strapi/Strapi.node.js", @@ -628,6 +633,7 @@ "mongodb": "3.6.6", "mqtt": "4.2.6", "mssql": "^6.2.0", + "node-ssh": "^11.0.0", "mysql2": "~2.2.0", "n8n-core": "~0.72.0", "nodemailer": "^6.5.0",