refactor(core): Centralize SSH Tunnel management (#9906)

Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-07-04 12:29:44 +02:00
committed by GitHub
parent 86018aa6e0
commit 85aa560a5d
25 changed files with 525 additions and 630 deletions

View File

@@ -1,11 +1,8 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { Client } from 'ssh2';
import type { QueryRunner } from '../helpers/interfaces';
import { createPool } from '../transport';
import type { MysqlNodeCredentials, QueryRunner } from '../helpers/interfaces';
import { configureQueryRunner } from '../helpers/utils';
import * as database from './database/Database.resource';
import type { MySqlType } from './node.type';
@@ -19,14 +16,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
nodeOptions.nodeVersion = this.getNode().typeVersion;
const credentials = await this.getCredentials('mySql');
const credentials = (await this.getCredentials('mySql')) as MysqlNodeCredentials;
let sshClient: Client | undefined = undefined;
if (credentials.sshTunnel) {
sshClient = new Client();
}
const pool = await createPool(credentials, nodeOptions, sshClient);
const pool = await createPool.call(this, credentials, nodeOptions);
const runQueries: QueryRunner = configureQueryRunner.call(this, nodeOptions, pool);
@@ -53,12 +45,7 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
`The operation "${operation}" is not supported!`,
);
}
} catch (error) {
throw error;
} finally {
if (sshClient) {
sshClient.end();
}
await pool.end();
}

View File

@@ -1,5 +1,5 @@
import type mysql2 from 'mysql2/promise';
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import type { IDataObject, INodeExecutionData, SSHCredentials } from 'n8n-workflow';
export type Mysql2Connection = mysql2.Connection;
export type Mysql2Pool = mysql2.Pool;
@@ -26,3 +26,23 @@ const INDEPENDENTLY = 'independently';
export const BATCH_MODE = { SINGLE, TRANSACTION, INDEPENDENTLY };
export type QueryMode = typeof SINGLE | typeof TRANSACTION | typeof INDEPENDENTLY;
type WithSSL =
| { ssl: false }
| { ssl: true; caCertificate: string; clientCertificate: string; clientPrivateKey: string };
type WithSSHTunnel =
| { sshTunnel: false }
| ({
sshTunnel: true;
} & SSHCredentials);
export type MysqlNodeCredentials = {
host: string;
port: number;
database: string;
user: string;
password: string;
connectTimeout: number;
} & WithSSL &
WithSSHTunnel;

View File

@@ -1,25 +1,19 @@
import type {
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialTestFunctions,
INodeCredentialTestResult,
} from 'n8n-workflow';
import { Client } from 'ssh2';
import { createPool } from '../transport';
import type { MysqlNodeCredentials } from '../helpers/interfaces';
export async function mysqlConnectionTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
const credentials = credential.data as ICredentialDataDecryptedObject;
const credentials = credential.data as MysqlNodeCredentials;
let sshClient: Client | undefined = undefined;
if (credentials.sshTunnel) {
sshClient = new Client();
}
const pool = await createPool(credentials, {}, sshClient);
const pool = await createPool.call(this, credentials);
try {
const connection = await pool.getConnection();
@@ -30,9 +24,6 @@ export async function mysqlConnectionTest(
message: error.message,
};
} finally {
if (sshClient) {
sshClient.end();
}
await pool.end();
}

View File

@@ -1,18 +1,13 @@
import type { IDataObject, ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
import { Client } from 'ssh2';
import { createPool } from '../transport';
import type { MysqlNodeCredentials } from '../helpers/interfaces';
export async function searchTables(this: ILoadOptionsFunctions): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials('mySql');
const credentials = (await this.getCredentials('mySql')) as MysqlNodeCredentials;
const nodeOptions = this.getNodeParameter('options', 0) as IDataObject;
let sshClient: Client | undefined = undefined;
if (credentials.sshTunnel) {
sshClient = new Client();
}
const pool = await createPool(credentials, nodeOptions, sshClient);
const pool = await createPool.call(this, credentials, nodeOptions);
try {
const connection = await pool.getConnection();
@@ -32,12 +27,7 @@ export async function searchTables(this: ILoadOptionsFunctions): Promise<INodeLi
}));
return { results };
} catch (error) {
throw error;
} finally {
if (sshClient) {
sshClient.end();
}
await pool.end();
}
}

View File

@@ -1,18 +1,13 @@
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { Client } from 'ssh2';
import { createPool } from '../transport';
import { escapeSqlIdentifier } from '../helpers/utils';
import type { MysqlNodeCredentials } from '../helpers/interfaces';
export async function getColumns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const credentials = await this.getCredentials('mySql');
const credentials = (await this.getCredentials('mySql')) as MysqlNodeCredentials;
const nodeOptions = this.getNodeParameter('options', 0) as IDataObject;
let sshClient: Client | undefined = undefined;
if (credentials.sshTunnel) {
sshClient = new Client();
}
const pool = await createPool(credentials, nodeOptions, sshClient);
const pool = await createPool.call(this, credentials, nodeOptions);
try {
const connection = await pool.getConnection();
@@ -39,12 +34,7 @@ export async function getColumns(this: ILoadOptionsFunctions): Promise<INodeProp
column.Null as string
}`,
}));
} catch (error) {
throw error;
} finally {
if (sshClient) {
sshClient.end();
}
await pool.end();
}
}

View File

@@ -1,82 +1,44 @@
import { ApplicationError } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow';
import { createServer, type AddressInfo } from 'node:net';
import mysql2 from 'mysql2/promise';
import type { Client, ConnectConfig } from 'ssh2';
import type {
ICredentialTestFunctions,
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
} from 'n8n-workflow';
import type { Mysql2Pool } from '../helpers/interfaces';
import { formatPrivateKey } from '@utils/utilities';
async function createSshConnectConfig(credentials: IDataObject) {
if (credentials.sshAuthenticateWith === 'password') {
return {
host: credentials.sshHost as string,
port: credentials.sshPort as number,
username: credentials.sshUser as string,
password: credentials.sshPassword as string,
} as ConnectConfig;
} else {
const options: ConnectConfig = {
host: credentials.sshHost as string,
username: credentials.sshUser as string,
port: credentials.sshPort as number,
privateKey: formatPrivateKey(credentials.privateKey as string),
};
if (credentials.passphrase) {
options.passphrase = credentials.passphrase as string;
}
return options;
}
}
import type { Mysql2Pool, MysqlNodeCredentials } from '../helpers/interfaces';
import { LOCALHOST } from '@utils/constants';
export async function createPool(
credentials: ICredentialDataDecryptedObject,
this: IExecuteFunctions | ICredentialTestFunctions | ILoadOptionsFunctions,
credentials: MysqlNodeCredentials,
options?: IDataObject,
sshClient?: Client,
): Promise<Mysql2Pool> {
if (credentials === undefined) {
throw new ApplicationError('Credentials not selected, select or add new credentials', {
level: 'warning',
});
}
const {
ssl,
caCertificate,
clientCertificate,
clientPrivateKey,
sshTunnel,
sshHost,
sshUser,
sshPassword,
sshPort,
sshMysqlPort,
privateKey,
passphrase,
sshAuthenticateWith,
...baseCredentials
} = credentials;
if (ssl) {
baseCredentials.ssl = {};
if (caCertificate) {
baseCredentials.ssl.ca = formatPrivateKey(caCertificate as string);
}
if (clientCertificate || clientPrivateKey) {
baseCredentials.ssl.cert = formatPrivateKey(clientCertificate as string);
baseCredentials.ssl.key = formatPrivateKey(clientPrivateKey as string);
}
}
const connectionOptions: mysql2.ConnectionOptions = {
...baseCredentials,
host: credentials.host,
port: credentials.port,
database: credentials.database,
user: credentials.user,
password: credentials.password,
multipleStatements: true,
supportBigNumbers: true,
};
if (credentials.ssl) {
connectionOptions.ssl = {};
if (credentials.caCertificate) {
connectionOptions.ssl.ca = formatPrivateKey(credentials.caCertificate);
}
if (credentials.clientCertificate || credentials.clientPrivateKey) {
connectionOptions.ssl.cert = formatPrivateKey(credentials.clientCertificate);
connectionOptions.ssl.key = formatPrivateKey(credentials.clientPrivateKey);
}
}
if (options?.nodeVersion && (options.nodeVersion as number) >= 2.1) {
connectionOptions.dateStrings = true;
}
@@ -93,46 +55,39 @@ export async function createPool(
connectionOptions.bigNumberStrings = true;
}
if (!sshTunnel) {
if (!credentials.sshTunnel) {
return mysql2.createPool(connectionOptions);
} else {
if (!sshClient) {
throw new ApplicationError('SSH Tunnel is enabled but no SSH Client was provided', {
level: 'warning',
});
if (credentials.sshAuthenticateWith === 'privateKey' && credentials.privateKey) {
credentials.privateKey = formatPrivateKey(credentials.privateKey as string);
}
const sshClient = await this.helpers.getSSHClient(credentials);
const tunnelConfig = await createSshConnectConfig(credentials);
const forwardConfig = {
srcHost: '127.0.0.1',
srcPort: sshMysqlPort as number,
dstHost: credentials.host as string,
dstPort: credentials.port as number,
};
const poolSetup = new Promise<mysql2.Pool>((resolve, reject) => {
sshClient
.on('ready', () => {
sshClient.forwardOut(
forwardConfig.srcHost,
forwardConfig.srcPort,
forwardConfig.dstHost,
forwardConfig.dstPort,
(err, stream) => {
if (err) reject(err);
const updatedDbServer = {
...connectionOptions,
stream,
};
const connection = mysql2.createPool(updatedDbServer);
resolve(connection);
},
);
})
.connect(tunnelConfig);
// Find a free TCP port
const localPort = await new Promise<number>((resolve) => {
const tempServer = createServer();
tempServer.listen(0, LOCALHOST, () => {
resolve((tempServer.address() as AddressInfo).port);
tempServer.close();
});
});
return await poolSetup;
const stream = await new Promise((resolve, reject) => {
sshClient.forwardOut(
LOCALHOST,
localPort,
credentials.host,
credentials.port,
(err, clientChannel) => {
if (err) return reject(err);
resolve(clientChannel);
},
);
});
return mysql2.createPool({
...connectionOptions,
stream,
});
}
}