refactor(core): Centralize SSH Tunnel management (#9906)
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
committed by
GitHub
parent
86018aa6e0
commit
85aa560a5d
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user