From 6153c79e0b2c2b53ad0683379d69e93cd3fdc889 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 6 Jan 2023 19:40:22 +0200 Subject: [PATCH 1/3] #RI-3974 ssh tunneling base implementation --- redisinsight/api/config/ormconfig.ts | 2 + redisinsight/api/package.json | 3 + redisinsight/api/src/core.module.ts | 3 + .../database/dto/create.database.dto.ts | 25 +++++- .../database/dto/update.database.dto.ts | 52 ++++++++++++ .../database/entities/database.entity.ts | 22 ++++- .../src/modules/database/models/database.ts | 21 +++++ .../repositories/local.database.repository.ts | 80 +++++++++++++++++-- .../modules/profiler/models/redis.observer.ts | 21 ----- .../providers/redis-observer.provider.ts | 2 +- .../modules/redis/redis-connection.factory.ts | 74 ++++++++++++----- .../ssh/dto/create.basic-ssh-options.dto.ts | 4 + .../ssh/dto/create.cert-ssh-options.dto.ts | 4 + .../ssh/entities/ssh-options.entity.ts | 51 ++++++++++++ .../api/src/modules/ssh/exceptions/index.ts | 4 + .../tunnel-connection-lost.exception.ts | 12 +++ ...unable-to-create-local-server.exception.ts | 12 +++ ...able-to-create-ssh-connection.exception.ts | 12 +++ .../unable-to-create-tunnel.exception.ts | 12 +++ .../api/src/modules/ssh/models/ssh-options.ts | 77 ++++++++++++++++++ .../api/src/modules/ssh/models/ssh-tunnel.ts | 78 ++++++++++++++++++ .../src/modules/ssh/ssh-tunnel.provider.ts | 67 ++++++++++++++++ .../api/src/modules/ssh/ssh.module.ts | 8 ++ .../transformers/ssh-options.transformer.ts | 11 +++ redisinsight/api/yarn.lock | 70 +++++++++++++++- 25 files changed, 673 insertions(+), 54 deletions(-) create mode 100644 redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts create mode 100644 redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts create mode 100644 redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/index.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts create mode 100644 redisinsight/api/src/modules/ssh/models/ssh-options.ts create mode 100644 redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts create mode 100644 redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts create mode 100644 redisinsight/api/src/modules/ssh/ssh.module.ts create mode 100644 redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index f71bbdccd4..4e38c3f1fc 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -10,6 +10,7 @@ import { SettingsEntity } from 'src/modules/settings/entities/settings.entity'; import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -31,6 +32,7 @@ const ormConfig = { PluginStateEntity, NotificationEntity, DatabaseAnalysisEntity, + SshOptionsEntity, ], migrations, }; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 5ab3dec344..73d6ba7e56 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -53,6 +53,7 @@ "body-parser": "^1.19.0", "class-transformer": "^0.2.3", "class-validator": "^0.12.2", + "detect-port": "^1.5.1", "dotenv": "^16.0.0", "express": "^4.17.1", "fs-extra": "^10.0.0", @@ -67,6 +68,7 @@ "socket.io": "^4.4.0", "source-map-support": "^0.5.19", "sqlite3": "^5.0.11", + "ssh2": "^1.11.0", "swagger-ui-express": "^4.1.4", "typeorm": "^0.3.9", "uuid": "^8.3.2", @@ -84,6 +86,7 @@ "@types/lodash": "^4.14.167", "@types/node": "14.14.10", "@types/socket.io": "^3.0.2", + "@types/ssh2": "^1.11.6", "@types/supertest": "^2.0.8", "@typescript-eslint/eslint-plugin": "^4.8.1", "@typescript-eslint/parser": "^4.8.1", diff --git a/redisinsight/api/src/core.module.ts b/redisinsight/api/src/core.module.ts index c9c91ee1b6..d549235f20 100644 --- a/redisinsight/api/src/core.module.ts +++ b/redisinsight/api/src/core.module.ts @@ -6,6 +6,7 @@ import { CertificateModule } from 'src/modules/certificate/certificate.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { RedisModule } from 'src/modules/redis/redis.module'; import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; +import { SshModule } from 'src/modules/ssh/ssh.module'; @Global() @Module({ @@ -17,6 +18,7 @@ import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; CertificateModule.register(), DatabaseModule.register(), RedisModule, + SshModule, ], exports: [ EncryptionModule, @@ -24,6 +26,7 @@ import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; CertificateModule, DatabaseModule, RedisModule, + SshModule, ], }) export class CoreModule {} diff --git a/redisinsight/api/src/modules/database/dto/create.database.dto.ts b/redisinsight/api/src/modules/database/dto/create.database.dto.ts index 94dac062a9..f23c4b3a8c 100644 --- a/redisinsight/api/src/modules/database/dto/create.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/create.database.dto.ts @@ -12,11 +12,18 @@ import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certific import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; import { caCertTransformer } from 'src/modules/certificate/transformers/ca-cert.transformer'; import { clientCertTransformer } from 'src/modules/certificate/transformers/client-cert.transformer'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; +import { sshOptionsTransformer } from 'src/modules/ssh/transformers/ssh-options.transformer'; -@ApiExtraModels(CreateCaCertificateDto, UseCaCertificateDto, CreateClientCertificateDto, UseClientCertificateDto) +@ApiExtraModels( + CreateCaCertificateDto, UseCaCertificateDto, + CreateClientCertificateDto, UseClientCertificateDto, + CreateBasicSshOptionsDto, CreateCertSshOptionsDto, +) export class CreateDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', 'nameFromProvider', 'provider', - 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', + 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', 'ssh', ] as const) { @ApiPropertyOptional({ description: 'CA Certificate', @@ -45,4 +52,18 @@ export class CreateDatabaseDto extends PickType(Database, [ @Type(clientCertTransformer) @ValidateNested() clientCert?: CreateClientCertificateDto | UseClientCertificateDto; + + @ApiPropertyOptional({ + description: 'SSH Options', + oneOf: [ + { $ref: getSchemaPath(CreateBasicSshOptionsDto) }, + { $ref: getSchemaPath(CreateCertSshOptionsDto) }, + ], + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(sshOptionsTransformer) + @ValidateNested() + sshOptions?: CreateBasicSshOptionsDto | CreateCertSshOptionsDto; } diff --git a/redisinsight/api/src/modules/database/dto/update.database.dto.ts b/redisinsight/api/src/modules/database/dto/update.database.dto.ts index 9946f1a5cf..12a111e8a3 100644 --- a/redisinsight/api/src/modules/database/dto/update.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/update.database.dto.ts @@ -13,6 +13,9 @@ import { clientCertTransformer } from 'src/modules/certificate/transformers/clie import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { CreateDatabaseDto } from 'src/modules/database/dto/create.database.dto'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; +import { sshOptionsTransformer } from 'src/modules/ssh/transformers/ssh-options.transformer'; export class UpdateDatabaseDto extends CreateDatabaseDto { @ValidateIf((object, value) => value !== undefined) @@ -28,6 +31,31 @@ export class UpdateDatabaseDto extends CreateDatabaseDto { @IsInt({ always: true }) port: number; + @ApiPropertyOptional({ + description: + 'Database username, if your database is ACL enabled, otherwise leave this field empty.', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + username?: string; + + @ApiPropertyOptional({ + description: + 'The password, if any, for your Redis database. ' + + 'If your database doesn’t require a password, leave this field empty.', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + password?: string; + @ApiPropertyOptional({ description: 'Logical database number.', type: Number, @@ -47,6 +75,15 @@ export class UpdateDatabaseDto extends CreateDatabaseDto { @Default(false) tls?: boolean; + @ApiPropertyOptional({ + description: 'Use SSH to connect.', + type: Boolean, + }) + @IsBoolean() + @IsOptional() + @Default(false) + ssh?: boolean; + @ApiPropertyOptional({ description: 'SNI servername', type: String, @@ -105,4 +142,19 @@ export class UpdateDatabaseDto extends CreateDatabaseDto { @ValidateNested() @Default(null) sentinelMaster?: SentinelMaster; + + @ApiPropertyOptional({ + description: 'SSH Options', + oneOf: [ + { $ref: getSchemaPath(CreateBasicSshOptionsDto) }, + { $ref: getSchemaPath(CreateCertSshOptionsDto) }, + ], + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(sshOptionsTransformer) + @ValidateNested() + @Default(null) + sshOptions?: CreateBasicSshOptionsDto | CreateCertSshOptionsDto; } diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 6c193c605b..1d137039a8 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -1,11 +1,12 @@ import { - Column, Entity, ManyToOne, PrimaryGeneratedColumn, + Column, Entity, ManyToOne, OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; import { DataAsJsonString } from 'src/common/decorators'; -import { Expose, Transform } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; export enum HostingProvider { UNKNOWN = 'UNKNOWN', @@ -162,4 +163,21 @@ export class DatabaseEntity { @Expose() @Column({ nullable: true }) new: boolean; + + @Expose() + @Column({ nullable: true }) + ssh: boolean; + + @Expose() + @OneToOne( + () => SshOptionsEntity, + (sshOptions) => sshOptions.database, + { + eager: true, + onDelete: 'CASCADE', + cascade: true, + }, + ) + @Type(() => SshOptionsEntity) + sshOptions: SshOptionsEntity; } diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 5e6b4c029c..b0054b9d62 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -17,6 +17,7 @@ import { import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { Endpoint } from 'src/common/models'; import { AdditionalRedisModule } from 'src/modules/database/models/additional.redis.module'; +import { SshOptions } from 'src/modules/ssh/models/ssh-options'; export class Database { @ApiProperty({ @@ -215,4 +216,24 @@ export class Database { @IsOptional() @IsBoolean({ always: true }) new?: boolean; + + @ApiPropertyOptional({ + description: 'Use SSH tunnel to connect.', + type: Boolean, + }) + @Expose() + @IsBoolean() + @IsOptional() + ssh?: boolean; + + @ApiPropertyOptional({ + description: 'SSH options', + type: SshOptions, + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(() => SshOptions) + @ValidateNested() + sshOptions?: SshOptions; } diff --git a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts index b0f82d6e59..cd5aa3dfba 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts @@ -9,20 +9,29 @@ import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { ModelEncryptor } from 'src/modules/encryption/model.encryptor'; import { CaCertificateRepository } from 'src/modules/certificate/repositories/ca-certificate.repository'; import { ClientCertificateRepository } from 'src/modules/certificate/repositories/client-certificate.repository'; +import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; @Injectable() export class LocalDatabaseRepository extends DatabaseRepository { private readonly modelEncryptor: ModelEncryptor; + private readonly sshModelEncryptor: ModelEncryptor; + constructor( @InjectRepository(DatabaseEntity) private readonly repository: Repository, + @InjectRepository(SshOptionsEntity) + private readonly sshOptionsRepository: Repository, private readonly caCertificateRepository: CaCertificateRepository, private readonly clientCertificateRepository: ClientCertificateRepository, private readonly encryptionService: EncryptionService, ) { super(); this.modelEncryptor = new ModelEncryptor(encryptionService, ['password', 'sentinelMasterPassword']); + this.sshModelEncryptor = new ModelEncryptor(encryptionService, [ + 'username', 'password', + 'privateKey', 'passphrase', + ]); } /** @@ -47,7 +56,7 @@ export class LocalDatabaseRepository extends DatabaseRepository { if (!entity) { return null; } - const model = classToClass(Database, await this.modelEncryptor.decryptEntity(entity, ignoreEncryptionErrors)); + const model = classToClass(Database, await this.decryptEntity(entity, ignoreEncryptionErrors)); if (entity.caCert) { model.caCert = await this.caCertificateRepository.get(entity.caCert.id); @@ -83,9 +92,9 @@ export class LocalDatabaseRepository extends DatabaseRepository { const entity = classToClass(DatabaseEntity, await this.populateCertificates(database)); return classToClass( Database, - await this.modelEncryptor.decryptEntity( + await this.decryptEntity( await this.repository.save( - await this.modelEncryptor.encryptEntity(entity), + await this.encryptEntity(entity), ), ), ); @@ -101,8 +110,35 @@ export class LocalDatabaseRepository extends DatabaseRepository { * @throws TBD */ public async update(id: string, database: Partial): Promise { - const entity = classToClass(DatabaseEntity, await this.populateCertificates(database as Database)); - await this.repository.update(id, await this.modelEncryptor.encryptEntity(entity)); + const oldEntity = await this.decryptEntity((await this.repository.findOneBy({ id })), true); + const newEntity = classToClass(DatabaseEntity, await this.populateCertificates(database as Database)); + + const mergeResult = this.repository.merge(oldEntity, newEntity); + + if (newEntity.caCert === null) { + mergeResult.caCert = null; + } + + if (newEntity.clientCert === null) { + mergeResult.clientCert = null; + } + + if (newEntity.sshOptions === null) { + mergeResult.sshOptions = null; + } + + const encrypted = await this.encryptEntity(mergeResult); + + await this.repository.save(encrypted); + + // workaround for one way cascade deletion + if (newEntity.sshOptions === null) { + await this.sshOptionsRepository.createQueryBuilder() + .delete() + .where('databaseId IS NULL') + .execute(); + } + return this.get(id); } @@ -134,4 +170,38 @@ export class LocalDatabaseRepository extends DatabaseRepository { return model; } + + /** + * Encrypt Database entity and SshOptions entity if present + * @param entity + * @private + */ + private async encryptEntity(entity: DatabaseEntity): Promise { + const encryptedEntity = await this.modelEncryptor.encryptEntity(entity); + + if (encryptedEntity.sshOptions) { + encryptedEntity.sshOptions = await this.sshModelEncryptor.encryptEntity(encryptedEntity.sshOptions); + } + + return encryptedEntity; + } + + /** + * Decrypt Database entity and SshOptions entity if present + * @param entity + * @param ignoreEncryptionErrors + * @private + */ + private async decryptEntity(entity: DatabaseEntity, ignoreEncryptionErrors = false): Promise { + const decryptedEntity = await this.modelEncryptor.decryptEntity(entity, ignoreEncryptionErrors); + + if (decryptedEntity.sshOptions) { + decryptedEntity.sshOptions = await this.sshModelEncryptor.decryptEntity( + decryptedEntity.sshOptions, + ignoreEncryptionErrors, + ); + } + + return decryptedEntity; + } } diff --git a/redisinsight/api/src/modules/profiler/models/redis.observer.ts b/redisinsight/api/src/modules/profiler/models/redis.observer.ts index f4a423c9f9..03fbca14f1 100644 --- a/redisinsight/api/src/modules/profiler/models/redis.observer.ts +++ b/redisinsight/api/src/modules/profiler/models/redis.observer.ts @@ -197,27 +197,6 @@ export class RedisObserver extends EventEmitter2 { * @param redis */ static async createShardObserver(redis: IORedis.Redis): Promise { - await RedisObserver.isMonitorAvailable(redis); return await redis.monitor() as IShardObserver; } - - /** - * HACK: ioredis do not handle error when a user has no permissions to run the 'monitor' command - * Here we try to send "monitor" command directly to throw error (like NOPERM) if any - * @param redis - */ - static async isMonitorAvailable(redis: IORedis.Redis): Promise { - // @ts-ignore - const duplicate = redis.duplicate({ - ...redis.options, - monitor: false, - lazyConnect: false, - connectionName: `redisinsight-monitor-perm-check-${Math.random()}`, - }); - - await duplicate.call('monitor'); - duplicate.disconnect(); - - return true; - } } diff --git a/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts index e08928579c..e2993fadb6 100644 --- a/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts +++ b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts @@ -105,7 +105,7 @@ export class RedisObserverProvider { */ private getRedisClientFn(clientMetadata: ClientMetadata): () => Promise { return async () => withTimeout( - this.databaseConnectionService.getOrCreateClient(clientMetadata), + this.databaseConnectionService.createClient(clientMetadata), serverConfig.requestTimeout, new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), ); diff --git a/redisinsight/api/src/modules/redis/redis-connection.factory.ts b/redisinsight/api/src/modules/redis/redis-connection.factory.ts index cd2240927a..ad66b80f39 100644 --- a/redisinsight/api/src/modules/redis/redis-connection.factory.ts +++ b/redisinsight/api/src/modules/redis/redis-connection.factory.ts @@ -8,6 +8,8 @@ import { cloneClassInstance, generateRedisConnectionName } from 'src/utils'; import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { ClientMetadata } from 'src/common/models'; import { ClusterOptions } from 'ioredis/built/cluster/ClusterOptions'; +import { SshTunnelProvider } from 'src/modules/ssh/ssh-tunnel.provider'; +import { TunnelConnectionLostException } from 'src/modules/ssh/exceptions'; const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); @@ -20,6 +22,10 @@ export interface IRedisConnectionOptions { export class RedisConnectionFactory { private logger = new Logger('RedisConnectionFactory'); + constructor( + private readonly sshTunnelProvider: SshTunnelProvider, + ) {} + // common retry strategy private retryStrategy = (times: number): number => { if (times < REDIS_CLIENTS_CONFIG.retryTimes) { @@ -155,30 +161,54 @@ export class RedisConnectionFactory { database: Database, options: IRedisConnectionOptions, ): Promise { - const config = await this.getRedisOptions(clientMetadata, database, options); + let tnl; - return await new Promise((resolve, reject) => { - try { - const connection = new Redis({ - ...config, - // cover cases when we are connecting to sentinel as to standalone to discover master groups - db: config.db > 0 && !database.sentinelMaster ? config.db : 0, - }); - connection.on('error', (e): void => { - this.logger.error('Failed connection to the redis database.', e); - reject(e); - }); - connection.on('ready', (): void => { - this.logger.log('Successfully connected to the redis database'); - resolve(connection); - }); - connection.on('reconnecting', (): void => { - this.logger.log('Reconnecting to the redis database'); - }); - } catch (e) { - reject(e); + try { + const config = await this.getRedisOptions(clientMetadata, database, options); + + if (database.ssh) { + tnl = await this.sshTunnelProvider.createTunnel(database); } - }) as Redis; + + return await new Promise((resolve, reject) => { + try { + if (tnl) { + tnl.on('error', (error) => { + reject(error); + }); + + tnl.on('close', () => { + reject(new TunnelConnectionLostException()); + }); + + config.host = tnl.serverAddress.host; + config.port = tnl.serverAddress.port; + } + + const connection = new Redis({ + ...config, + // cover cases when we are connecting to sentinel as to standalone to discover master groups + db: config.db > 0 && !database.sentinelMaster ? config.db : 0, + }); + connection.on('error', (e): void => { + this.logger.error('Failed connection to the redis database.', e); + reject(e); + }); + connection.on('ready', (): void => { + this.logger.log('Successfully connected to the redis database'); + resolve(connection); + }); + connection.on('reconnecting', (): void => { + this.logger.log('Reconnecting to the redis database'); + }); + } catch (e) { + reject(e); + } + }) as Redis; + } catch (e) { + tnl?.close?.(); + throw e; + } } /** diff --git a/redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts b/redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts new file mode 100644 index 0000000000..a5e2decaae --- /dev/null +++ b/redisinsight/api/src/modules/ssh/dto/create.basic-ssh-options.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { SshOptions } from 'src/modules/ssh/models/ssh-options'; + +export class CreateBasicSshOptionsDto extends OmitType(SshOptions, ['privateKey', 'passphrase'] as const) {} diff --git a/redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts b/redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts new file mode 100644 index 0000000000..901392f108 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/dto/create.cert-ssh-options.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { SshOptions } from 'src/modules/ssh/models/ssh-options'; + +export class CreateCertSshOptionsDto extends OmitType(SshOptions, ['password'] as const) {} diff --git a/redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts b/redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts new file mode 100644 index 0000000000..d39163caac --- /dev/null +++ b/redisinsight/api/src/modules/ssh/entities/ssh-options.entity.ts @@ -0,0 +1,51 @@ +import { + Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, +} from 'typeorm'; +import { Expose } from 'class-transformer'; +import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; + +@Entity('ssh_options') +export class SshOptionsEntity { + @Expose() + @PrimaryGeneratedColumn('uuid') + id: string; + + @Expose() + @Column({ nullable: false }) + host: string; + + @Expose() + @Column({ nullable: false }) + port: number; + + @Expose() + @Column({ nullable: true }) + encryption: string; + + @Expose() + @Column({ nullable: true }) + username: string; + + @Expose() + @Column({ nullable: true }) + password: string; + + @Expose() + @Column({ nullable: true }) + privateKey: string; + + @Expose() + @Column({ nullable: true }) + passphrase: string; + + @OneToOne( + () => DatabaseEntity, + (database) => database.sshOptions, + { + nullable: true, + onDelete: 'CASCADE', + }, + ) + @JoinColumn() + database: DatabaseEntity; +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/index.ts b/redisinsight/api/src/modules/ssh/exceptions/index.ts new file mode 100644 index 0000000000..ae6dfb8cc0 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/index.ts @@ -0,0 +1,4 @@ +export * from './unable-to-create-ssh-connection.exception'; +export * from './unable-to-create-tunnel.exception'; +export * from './tunnel-connection-lost.exception'; +export * from './unable-to-create-local-server.exception'; diff --git a/redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts new file mode 100644 index 0000000000..cc453a7b8a --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/tunnel-connection-lost.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class TunnelConnectionLostException extends HttpException { + constructor(message = '') { + const prepend = 'Tunnel connection was lost.'; + super({ + message: `${prepend} ${message}`, + name: 'TunnelConnectionLostException', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts new file mode 100644 index 0000000000..1c022d38c3 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-local-server.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToCreateLocalServerException extends HttpException { + constructor(message = '') { + const prepend = 'Unable to create local server.'; + super({ + message: `${prepend} ${message}`, + name: 'UnableToCreateLocalServerException', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts new file mode 100644 index 0000000000..6e2daa68e7 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-ssh-connection.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToCreateSshConnectionException extends HttpException { + constructor(message = '') { + const prepend = 'Unable to create ssh connection.'; + super({ + message: `${prepend} ${message}`, + name: 'UnableToCreateSshConnectionException', + statusCode: 503, + }, 503); + } +} diff --git a/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts new file mode 100644 index 0000000000..0c7addc967 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/exceptions/unable-to-create-tunnel.exception.ts @@ -0,0 +1,12 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToCreateTunnelException extends HttpException { + constructor(message = '') { + const prepend = 'Unable to create tunnel.'; + super({ + message: `${prepend} ${message}`, + name: 'UnableToCreateTunnelException', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/ssh/models/ssh-options.ts b/redisinsight/api/src/modules/ssh/models/ssh-options.ts new file mode 100644 index 0000000000..4b75b6331f --- /dev/null +++ b/redisinsight/api/src/modules/ssh/models/ssh-options.ts @@ -0,0 +1,77 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; +import { Default } from 'src/common/decorators'; + +export class SshOptions { + @ApiProperty({ + description: 'The hostname of SSH server', + type: String, + default: 'localhost', + }) + @Expose() + @IsNotEmpty() + @IsString({ always: true }) + @Default(null) + host: string; + + @ApiProperty({ + description: 'The port of SSH server', + type: Number, + default: 22, + }) + @Expose() + @IsNotEmpty() + @IsInt({ always: true }) + @Default(null) + port: number; + + @ApiPropertyOptional({ + description: 'SSH username', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + username?: string; + + @ApiPropertyOptional({ + description: 'The SSH password', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + password?: string; + + @ApiPropertyOptional({ + description: 'The SSH private key', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + privateKey?: string; + + @ApiPropertyOptional({ + description: 'The SSH passphrase', + type: String, + }) + @Expose() + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + @Default(null) + passphrase?: string; +} diff --git a/redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts b/redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts new file mode 100644 index 0000000000..78eab3a21a --- /dev/null +++ b/redisinsight/api/src/modules/ssh/models/ssh-tunnel.ts @@ -0,0 +1,78 @@ +import { AddressInfo, Server } from 'net'; +import { EventEmitter } from 'events'; +import { Client } from 'ssh2'; +import { Endpoint } from 'src/common/models'; +import { UnableToCreateTunnelException } from 'src/modules/ssh/exceptions'; + +export interface ISshTunnelOptions { + targetHost: string, + targetPort: number, +} + +export class SshTunnel extends EventEmitter { + private readonly server: Server; + + private readonly client: Client; + + public readonly serverAddress: Endpoint; + + constructor(server: Server, client: Client, options: ISshTunnelOptions) { + super(); + this.server = server; + this.client = client; + const address = this.server?.address() as AddressInfo; + this.serverAddress = { + host: address?.address, + port: address?.port, + }; + + this.init(options); + } + + public close() { + this.server?.close?.(); + this.client?.end?.(); + this.server?.removeAllListeners?.(); + this.client?.removeAllListeners?.(); + this.emit('close'); + this.removeAllListeners(); + } + + private error(e: Error) { + this.emit('error', e); + } + + private init(options: ISshTunnelOptions) { + this.server.on('close', this.close); + this.client.on('close', this.close); + // close since net server is not being closed automatically when we need this + this.server.on('error', this.close); + this.client.on('error', this.error); + + this.server.on('connection', (connection) => { + this.client.forwardOut( + this.serverAddress?.host, + this.serverAddress?.port, + options.targetHost, + options.targetPort, + (e, stream) => { + if (e) { + return this.emit('error', new UnableToCreateTunnelException(e.message)); + } + + return connection.pipe(stream).pipe(connection); + }, + ); + + connection.on('error', (e) => { + this.client.emit('error', e); + }); + + connection.on('close', () => { + // close server and client connections (entire tunnel) when forward connection was lost + // todo: improve this to keep tunnel connection when there are active forward connections inside + this.close(); + }); + }); + } +} diff --git a/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts new file mode 100644 index 0000000000..5ced83a7c6 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/ssh-tunnel.provider.ts @@ -0,0 +1,67 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import * as detectPort from 'detect-port'; +import { Database } from 'src/modules/database/models/database'; +import { Server, createServer } from 'net'; +import { Client } from 'ssh2'; +import { SshTunnel } from 'src/modules/ssh/models/ssh-tunnel'; +import { + UnableToCreateLocalServerException, + UnableToCreateSshConnectionException, + UnableToCreateTunnelException, +} from 'src/modules/ssh/exceptions'; + +@Injectable() +export class SshTunnelProvider { + private async createServer(): Promise { + return new Promise((resolve, reject) => { + try { + const server = createServer(); + + server.on('listening', () => resolve(server)); + server.on('error', (e) => { + reject(new UnableToCreateLocalServerException(e.message)); + }); + + detectPort(50000) + .then((port) => { + server.listen({ + host: '127.0.0.1', + port, + }); + }) + .catch(reject); + } catch (e) { + reject(e); + } + }); + } + + private async createClient(options): Promise { + return new Promise((resolve, reject) => { + const conn = new Client(); + conn.on('ready', () => resolve(conn)); + conn.on('error', (e) => { + reject(new UnableToCreateSshConnectionException(e.message)); + }); + conn.connect(options); + }); + } + + public async createTunnel(database: Database) { + try { + const client = await this.createClient(database?.sshOptions); + const server = await this.createServer(); + + return new SshTunnel(server, client, { + targetHost: database.host, + targetPort: database.port, + }); + } catch (e) { + if (e instanceof HttpException) { + throw e; + } + + throw new UnableToCreateTunnelException(e.message); + } + } +} diff --git a/redisinsight/api/src/modules/ssh/ssh.module.ts b/redisinsight/api/src/modules/ssh/ssh.module.ts new file mode 100644 index 0000000000..1b98818cef --- /dev/null +++ b/redisinsight/api/src/modules/ssh/ssh.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SshTunnelProvider } from 'src/modules/ssh/ssh-tunnel.provider'; + +@Module({ + providers: [SshTunnelProvider], + exports: [SshTunnelProvider], +}) +export class SshModule {} diff --git a/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts b/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts new file mode 100644 index 0000000000..d089df0ae4 --- /dev/null +++ b/redisinsight/api/src/modules/ssh/transformers/ssh-options.transformer.ts @@ -0,0 +1,11 @@ +import { get } from 'lodash'; +import { TypeHelpOptions } from 'class-transformer'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; + +export const sshOptionsTransformer = (data: TypeHelpOptions) => { + if (get(data?.object, 'sshOptions.privateKey')) { + return CreateCertSshOptionsDto; + } + return CreateBasicSshOptionsDto; +}; diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index beaebe7663..c2fe6fe3cc 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -1135,6 +1135,13 @@ dependencies: socket.io "*" +"@types/ssh2@^1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.11.6.tgz#c114d15a3cfd2ba2f7ef219a2020c44f0fb8a01b" + integrity sha512-8Mf6bhzYYBLEB/G6COux7DS/F5bCWwojv/qFo2yH/e4cLzAavJnxvFXrYW59iKfXdhG6OmzJcXDasgOb/s0rxw== + dependencies: + "@types/node" "*" + "@types/stack-utils@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" @@ -1442,6 +1449,11 @@ acorn@^8.5.0, acorn@^8.7.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== +address@^1.0.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e" + integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA== + adm-zip@^0.5.9: version "0.5.9" resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.9.tgz#b33691028333821c0cf95c31374c5462f2905a83" @@ -1709,6 +1721,13 @@ arrify@^1.0.0: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= +asn1@^0.2.4: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -1855,6 +1874,13 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -1995,6 +2021,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +buildcheck@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.3.tgz#70451897a95d80f7807e68fc412eb2e7e35ff4d5" + integrity sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA== + busboy@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -2545,6 +2576,14 @@ cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +cpu-features@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.4.tgz#0023475bb4f4c525869c162e4108099e35bf19d8" + integrity sha512-fKiZ/zp1mUwQbnzb9IghXtHtDoTMtNeb8oYGx6kX2SYfhnG0HNdBEBIzB9b5KlXu5DQPhfy3mInbBxFcgwAr3A== + dependencies: + buildcheck "0.0.3" + nan "^2.15.0" + create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -2798,6 +2837,14 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +detect-port@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.5.1.tgz#451ca9b6eaf20451acb0799b8ab40dff7718727b" + integrity sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ== + dependencies: + address "^1.0.1" + debug "4" + diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" @@ -5902,6 +5949,11 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.15.0, nan@^2.16.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + nanoid@3.1.20: version "3.1.20" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" @@ -7025,7 +7077,7 @@ safe-stable-stringify@^1.1.0: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz#c8a220ab525cd94e60ebf47ddc404d610dc5d84a" integrity sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw== -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -7522,6 +7574,17 @@ sqlite3@^5.0.11: optionalDependencies: node-gyp "8.x" +ssh2@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.11.0.tgz#ce60186216971e12f6deb553dcf82322498fe2e4" + integrity sha512-nfg0wZWGSsfUe/IBJkXVll3PEZ//YH2guww+mP88gTpuSU4FtZN7zu9JoeTGOyCNx2dTDtT9fOpWwlzyj4uOOw== + dependencies: + asn1 "^0.2.4" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.4" + nan "^2.16.0" + ssri@^8.0.0, ssri@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" @@ -8118,6 +8181,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" From b5c9c2e0f63b99daef494e8c267ded30f9ee77d1 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 6 Jan 2023 20:45:17 +0200 Subject: [PATCH 2/3] add ssh2 to lazy import --- configs/webpack.config.base.js | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/webpack.config.base.js b/configs/webpack.config.base.js index a58581b672..a2000ade16 100644 --- a/configs/webpack.config.base.js +++ b/configs/webpack.config.base.js @@ -49,6 +49,7 @@ export default { new webpack.IgnorePlugin({ checkResource(resource) { const lazyImports = [ + 'ssh2', '@nestjs/microservices', // '@nestjs/platform-express', // 'pnpapi', From 9118ee3e17b16028d8ccabbb444cd09b40a49b39 Mon Sep 17 00:00:00 2001 From: Artem Date: Fri, 6 Jan 2023 22:21:35 +0200 Subject: [PATCH 3/3] fix deps issue + add migrations --- configs/webpack.config.base.js | 1 - .../migration/1673035852335-ssh-options.ts | 30 +++++++++++ redisinsight/api/migration/index.ts | 2 + redisinsight/package.json | 3 +- redisinsight/yarn.lock | 50 ++++++++++++++++++- 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 redisinsight/api/migration/1673035852335-ssh-options.ts diff --git a/configs/webpack.config.base.js b/configs/webpack.config.base.js index a2000ade16..a58581b672 100644 --- a/configs/webpack.config.base.js +++ b/configs/webpack.config.base.js @@ -49,7 +49,6 @@ export default { new webpack.IgnorePlugin({ checkResource(resource) { const lazyImports = [ - 'ssh2', '@nestjs/microservices', // '@nestjs/platform-express', // 'pnpapi', diff --git a/redisinsight/api/migration/1673035852335-ssh-options.ts b/redisinsight/api/migration/1673035852335-ssh-options.ts new file mode 100644 index 0000000000..0b99e5961a --- /dev/null +++ b/redisinsight/api/migration/1673035852335-ssh-options.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class sshOptions1673035852335 implements MigrationInterface { + name = 'sshOptions1673035852335' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "ssh_options" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "encryption" varchar, "username" varchar, "password" varchar, "privateKey" varchar, "passphrase" varchar, "databaseId" varchar, CONSTRAINT "REL_fe3c3f8b1246e4824a3fb83047" UNIQUE ("databaseId"))`); + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, "new" boolean, "ssh" boolean, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + await queryRunner.query(`CREATE TABLE "temporary_ssh_options" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "encryption" varchar, "username" varchar, "password" varchar, "privateKey" varchar, "passphrase" varchar, "databaseId" varchar, CONSTRAINT "REL_fe3c3f8b1246e4824a3fb83047" UNIQUE ("databaseId"), CONSTRAINT "FK_fe3c3f8b1246e4824a3fb83047d" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_ssh_options"("id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId") SELECT "id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId" FROM "ssh_options"`); + await queryRunner.query(`DROP TABLE "ssh_options"`); + await queryRunner.query(`ALTER TABLE "temporary_ssh_options" RENAME TO "ssh_options"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ssh_options" RENAME TO "temporary_ssh_options"`); + await queryRunner.query(`CREATE TABLE "ssh_options" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "encryption" varchar, "username" varchar, "password" varchar, "privateKey" varchar, "passphrase" varchar, "databaseId" varchar, CONSTRAINT "REL_fe3c3f8b1246e4824a3fb83047" UNIQUE ("databaseId"))`); + await queryRunner.query(`INSERT INTO "ssh_options"("id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId") SELECT "id", "host", "port", "encryption", "username", "password", "privateKey", "passphrase", "databaseId" FROM "temporary_ssh_options"`); + await queryRunner.query(`DROP TABLE "temporary_ssh_options"`); + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, "new" boolean, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "ssh_options"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index a2a4d1b284..c5964662cc 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -22,6 +22,7 @@ import { databaseAnalysisExpirationGroups1664886479051 } from './1664886479051-d import { workbenchExecutionTime1667368983699 } from './1667368983699-workbench-execution-time'; import { database1667477693934 } from './1667477693934-database'; import { databaseNew1670252337342 } from './1670252337342-database-new'; +import { sshOptions1673035852335 } from './1673035852335-ssh-options'; export default [ initialMigration1614164490968, @@ -48,4 +49,5 @@ export default [ workbenchExecutionTime1667368983699, database1667477693934, databaseNew1670252337342, + sshOptions1673035852335, ]; diff --git a/redisinsight/package.json b/redisinsight/package.json index 2cbd657ba9..0b0d0a1b0b 100644 --- a/redisinsight/package.json +++ b/redisinsight/package.json @@ -13,6 +13,7 @@ "scripts": {}, "dependencies": { "keytar": "^7.9.0", - "sqlite3": "^5.0.11" + "sqlite3": "^5.0.11", + "ssh2": "^1.11.0" } } diff --git a/redisinsight/yarn.lock b/redisinsight/yarn.lock index 294fa3fcbd..685ee0c0b6 100644 --- a/redisinsight/yarn.lock +++ b/redisinsight/yarn.lock @@ -98,6 +98,13 @@ are-we-there-yet@^3.0.0: delegates "^1.0.0" readable-stream "^3.6.0" +asn1@^0.2.4: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -108,6 +115,13 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + bl@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -133,6 +147,11 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buildcheck@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.3.tgz#70451897a95d80f7807e68fc412eb2e7e35ff4d5" + integrity sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA== + cacache@^15.2.0: version "15.3.0" resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.3.0.tgz#dc85380fb2f556fe3dda4c719bfa0ec875a7f1eb" @@ -187,6 +206,14 @@ console-control-strings@^1.0.0, console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +cpu-features@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.4.tgz#0023475bb4f4c525869c162e4108099e35bf19d8" + integrity sha512-fKiZ/zp1mUwQbnzb9IghXtHtDoTMtNeb8oYGx6kX2SYfhnG0HNdBEBIzB9b5KlXu5DQPhfy3mInbBxFcgwAr3A== + dependencies: + buildcheck "0.0.3" + nan "^2.15.0" + debug@4, debug@^4.1.0, debug@^4.3.3: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -576,6 +603,11 @@ ms@^2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nan@^2.15.0, nan@^2.16.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + napi-build-utils@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" @@ -747,7 +779,7 @@ safe-buffer@^5.0.1, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -826,6 +858,17 @@ sqlite3@^5.0.11: optionalDependencies: node-gyp "8.x" +ssh2@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.11.0.tgz#ce60186216971e12f6deb553dcf82322498fe2e4" + integrity sha512-nfg0wZWGSsfUe/IBJkXVll3PEZ//YH2guww+mP88gTpuSU4FtZN7zu9JoeTGOyCNx2dTDtT9fOpWwlzyj4uOOw== + dependencies: + asn1 "^0.2.4" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.4" + nan "^2.16.0" + ssri@^8.0.0, ssri@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" @@ -906,6 +949,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"