Skip to content

Commit

Permalink
refactor(datasource-sql): improve the uri management (#724)
Browse files Browse the repository at this point in the history
  • Loading branch information
Scra3 authored Jun 13, 2023
1 parent a3b8aed commit 618d7ac
Show file tree
Hide file tree
Showing 18 changed files with 681 additions and 819 deletions.
267 changes: 267 additions & 0 deletions packages/datasource-sql/src/connection/connection-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { Logger } from '@forestadmin/datasource-toolkit';
import { Dialect, Sequelize, Options as SequelizeOptions } from 'sequelize';

import { DatabaseConnectError } from './errors';
import connect from './index';
import {
PlainConnectionOptions,
PlainConnectionOptionsOrUri,
ProxyOptions,
SslMode,
} from '../types';

/**
* Connection options.
* This wrapper is constructed from a plain object or a URI string.
*
* It is capable of parsing them, and providing an interface to the rest of the code to:
* - Build parameters to create sequelize instances
* - Build connections options with both dialect and sslmode resolved (for quick startup in cloud)
* - Provide safe urls for error messages (without credentials)
* - Play with the host and port without breaking SSL servername / error messages (for the proxy)
*/
export default class ConnectionOptions {
proxyOptions?: ProxyOptions;

private initialHost: string;
private initialPort: number;
private logger?: Logger;
private sequelizeOptions: SequelizeOptions;
private sslMode: SslMode;
private uri?: URL;

/**
* Database URI without credentials, which can be used in error messages.
* Ensure that this is never substituted by the proxy, nor that it includes credentials.
*/
get debugDatabaseUri(): string {
const dialect = this.dialect ?? '?';
const port = this.initialPort ?? '?';
const database = this.database ?? '?';

return `${dialect}://${this.initialHost}:${port}/${database}`;
}

/** Proxy URI without credentials, which can be used in error messages INTERNALLY */
get debugProxyUri(): string {
return this.proxyOptions ? `tcp://${this.proxyOptions.host}:${this.proxyOptions.port}` : 'none';
}

get dialect(): Dialect {
let dialect = this.uri?.protocol?.slice(0, -1) || this.sequelizeOptions.dialect;
if (dialect === 'mysql2') dialect = 'mysql';
else if (dialect === 'tedious') dialect = 'mssql';
else if (dialect === 'pg' || dialect === 'postgresql') dialect = 'postgres';

return dialect as Dialect;
}

get host(): string {
return this.uri?.hostname ?? this.sequelizeOptions.host ?? 'localhost';
}

get port(): number {
let port = Number(this.uri?.port) || this.sequelizeOptions.port;

if (!port) {
// Use default port for known dialects otherwise
if (this.dialect === 'postgres') port = 5432;
else if (this.dialect === 'mssql') port = 1433;
else if (this.dialect === 'mysql' || this.dialect === 'mariadb') port = 3306;
}

return port;
}

get database(): string {
return this.uri?.pathname?.slice(1) || this.sequelizeOptions.database;
}

constructor(options: PlainConnectionOptionsOrUri, logger?: Logger) {
this.logger = logger;

if (typeof options === 'string') {
this.uri = this.parseDatabaseUri(options);
this.sequelizeOptions = {};
} else {
const { uri, sslMode, proxySocks, ...sequelizeOptions } = options;

this.proxyOptions = proxySocks;
this.sequelizeOptions = sequelizeOptions;
this.sslMode = sslMode ?? 'manual';
this.uri = uri ? this.parseDatabaseUri(uri) : null;
}

if (this.uri?.toString?.() !== 'sqlite::memory:') {
// Save initial host & port (for SSL and error messages)
this.initialHost = this.host;
this.initialPort = this.port;

// Check required options
if (!this.port) throw new DatabaseConnectError(`Port is required`, this.debugDatabaseUri);
if (!this.dialect)
throw new DatabaseConnectError(`Dialect is required`, this.debugDatabaseUri);
} else {
this.initialHost = ':memory:';
this.initialPort = 0;
}
}

changeHostAndPort(host: string, port: number): void {
// Host
if (this.uri) this.uri.hostname = host;
else this.sequelizeOptions.host = host;

// Port
if (this.uri) this.uri.port = String(port);
else this.sequelizeOptions.port = port;
}

async buildPreprocessedOptions(): Promise<PlainConnectionOptions> {
const options = { ...this.sequelizeOptions } as PlainConnectionOptions;

if (this.uri) options.uri = this.uri.toString();
if (this.proxyOptions) options.proxySocks = this.proxyOptions;
options.dialect = this.dialect;
options.sslMode = await this.computeSslMode();

return options;
}

/** Options that can be passed to the sequelize constructor */
async buildSequelizeCtorOptions(): Promise<[SequelizeOptions] | [string, SequelizeOptions]> {
const options = { ...this.sequelizeOptions };

options.dialect = this.dialect;
options.logging = this.makeSequelizeLogging();
options.schema = this.makeSequelizeSchema();
options.dialectOptions = {
...(options.dialectOptions ?? {}),
...(await this.makeSequelizeDialectOptions()),
};

return this.uri ? [this.uri.toString(), options] : [options];
}

private parseDatabaseUri(str: string): URL {
const message =
`Connection Uri "${str}" provided to SQL data source is not valid. ` +
`Should be <dialect>://<connection>.`;

if (str !== 'sqlite::memory:' && !/.*:\/\//g.test(str))
throw new DatabaseConnectError(message, str);

try {
return new URL(str);
} catch {
throw new DatabaseConnectError(message, str);
}
}

/** Helper to fill the sequelize's options.schema */
private makeSequelizeSchema(): SequelizeOptions['schema'] {
return this.uri?.searchParams.get('schema') || this.sequelizeOptions.schema || null;
}

/** Helper to fill the sequelize's options.logging */
private makeSequelizeLogging(): SequelizeOptions['logging'] {
return this.logger
? (sql: string) => this.logger?.('Debug', sql.substring(sql.indexOf(':') + 2))
: false;
}

/** Helper to fill the sequelize's options.dialectOptions */
private async makeSequelizeDialectOptions(): Promise<SequelizeOptions['dialectOptions']> {
const sslMode = await this.computeSslMode();

switch (this.dialect) {
case 'mariadb':
if (sslMode === 'disabled') return { ssl: false };
if (sslMode === 'required') return { ssl: { rejectUnauthorized: false } };
if (sslMode === 'verify') return { ssl: true };
break;

case 'mssql':
if (sslMode === 'disabled') return { options: { encrypt: false } };
if (sslMode === 'required')
return { options: { encrypt: true, trustServerCertificate: true } };
if (sslMode === 'verify')
return { options: { encrypt: true, trustServerCertificate: false } };
break;

case 'mysql':
if (sslMode === 'disabled') return { ssl: false };
if (sslMode === 'required') return { ssl: { rejectUnauthorized: false } };
if (sslMode === 'verify') return { ssl: { rejectUnauthorized: true } };
break;

case 'postgres':
if (sslMode === 'disabled') return { ssl: false };

// Pass servername to the net.tlsSocket constructor.

// This is done so that
// - Certificate verification still work when using a proxy server to reach the db (we are
// forced to use a tcp reverse proxy because some drivers do not support them)
// - Providers which use SNI to route requests to the correct database still work (most
// notably neon.tech).

// Note that we should either do that for the other vendors (if possible), or
// replace the reverse proxy by a forward proxy (when supported).
if (sslMode === 'required')
return {
ssl: { require: true, rejectUnauthorized: false, servername: this.initialHost },
};
if (sslMode === 'verify')
return { ssl: { require: true, rejectUnauthorized: true, servername: this.initialHost } };
break;

case 'db2':
case 'oracle':
case 'snowflake':
case 'sqlite':
default:
if (sslMode && sslMode !== 'manual') {
this.logger?.('Warn', `ignoring sslMode=${sslMode} (not supported for ${this.dialect})`);
}

return {};
}
}

private async computeSslMode(): Promise<SslMode> {
if (this.sslMode !== 'preferred') {
return this.sslMode ?? 'manual';
}

// When NODE_TLS_REJECT_UNAUTHORIZED is set to 0, we skip the 'verify' mode, as we know it will
// always work locally, but not when deploying to another environment.
const modes: SslMode[] = ['verify', 'required', 'disabled'];
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') modes.shift();

let error: Error;

for (const sslMode of modes) {
let sequelize: Sequelize;

try {
// eslint-disable-next-line no-await-in-loop
sequelize = await connect(
new ConnectionOptions({
uri: this.uri?.toString(),
sslMode,
...this.sequelizeOptions,
}),
);

return sslMode;
} catch (e) {
error = e;
} finally {
await sequelize?.close(); // eslint-disable-line no-await-in-loop
}
}

throw error;
}
}
79 changes: 23 additions & 56 deletions packages/datasource-sql/src/connection/errors.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,42 @@
// eslint-disable-next-line max-classes-per-file
import { ConnectionOptionsObj } from '../types';
/* eslint-disable max-classes-per-file */

function sanitizeUri(uri: string): string {
const uriObject = new URL(uri);
export type ErrorSource = 'Proxy' | 'Database';

if (uriObject.password) {
uriObject.password = '**sanitizedPassword**';
}

return uriObject.toString();
}

export type SourceError = 'Proxy' | 'Database';
abstract class BaseError extends Error {
abstract readonly source: ErrorSource;
readonly uri: string;
readonly details: string;

class BaseError extends Error {
public source: SourceError;
public uri: string;
public readonly details: string;

constructor(message: string, details?: string) {
const messageWithDetails = details ? `${message}\n${details}` : message;
super(messageWithDetails);
constructor(message: string, uri: string, details?: string) {
super(details ? `${message}\n${details}` : message);

this.name = this.constructor.name;
this.details = details;
this.uri = uri;
}
}

export class DatabaseConnectError extends BaseError {
constructor(message: string, databaseUri?: string, source: SourceError = 'Database') {
if (databaseUri) {
const sanitizedUri = sanitizeUri(databaseUri);
super(`Unable to connect to the given uri: ${sanitizedUri}.`, message);
this.uri = sanitizedUri;
} else {
super(`Unable to connect to the given uri.`, message);
}
readonly source: ErrorSource;

constructor(message: string, debugDatabaseUri: string, source: ErrorSource = 'Database') {
super(`Unable to connect to the given uri: ${debugDatabaseUri}.`, debugDatabaseUri, message);

this.name = this.constructor.name;
this.source = source;
}
}

export class ProxyConnectError extends BaseError {
constructor(message: string, proxyConfig?: ConnectionOptionsObj['proxySocks']) {
const defaultMessage = 'Your proxy has encountered an error.';

if (proxyConfig) {
const sanitizedUri = ProxyConnectError.buildSanitizedUriFromConfig(proxyConfig);
super(`${defaultMessage} Unable to connect to the given uri: ${sanitizedUri}.`, message);
this.uri = sanitizedUri;
} else {
super(defaultMessage, message);
}

this.name = this.constructor.name;
this.source = 'Proxy';
}

private static buildSanitizedUriFromConfig(
proxyConfig: ConnectionOptionsObj['proxySocks'],
): string {
const proxyUri = new URL(`socks://${proxyConfig.host}:${proxyConfig.port}`);

if (proxyConfig.userId) {
proxyUri.username = proxyConfig.userId;
}
readonly source = 'Proxy';

if (proxyConfig.password) {
proxyUri.password = proxyConfig.password;
}
constructor(message: string, debugProxyUri: string) {
// remove tcp protocol because its not added by the user
const sanitizedUri = debugProxyUri.replace('tcp://', '');

return sanitizeUri(proxyUri.toString()).replace('socks://', '');
super(
`Your proxy has encountered an error. Unable to connect to the given uri: ${sanitizedUri}.`,
sanitizedUri,
message,
);
}
}
Loading

0 comments on commit 618d7ac

Please sign in to comment.