diff --git a/redisinsight/api/src/__mocks__/database-import.ts b/redisinsight/api/src/__mocks__/database-import.ts index f94da3ceb5..e15d864f41 100644 --- a/redisinsight/api/src/__mocks__/database-import.ts +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -62,3 +62,7 @@ export const mockDatabaseImportAnalytics = jest.fn(() => ({ sendImportResults: jest.fn(), sendImportFailed: jest.fn(), })); + +export const mockCertificateImportService = jest.fn(() => { + +}); diff --git a/redisinsight/api/src/common/utils/certificate-import.util.ts b/redisinsight/api/src/common/utils/certificate-import.util.ts new file mode 100644 index 0000000000..77b5fe3312 --- /dev/null +++ b/redisinsight/api/src/common/utils/certificate-import.util.ts @@ -0,0 +1,7 @@ +import { parse } from 'path'; +import { readFileSync } from 'fs'; + +export const isValidPemCertificate = (cert: string): boolean => cert.startsWith('-----BEGIN CERTIFICATE-----'); +export const isValidPemPrivateKey = (cert: string): boolean => cert.startsWith('-----BEGIN PRIVATE KEY-----'); +export const getPemBodyFromFileSync = (path: string): string => readFileSync(path).toString('utf8'); +export const getCertNameFromFilename = (path: string): string => parse(path).name; diff --git a/redisinsight/api/src/common/utils/index.ts b/redisinsight/api/src/common/utils/index.ts new file mode 100644 index 0000000000..ee0efba08b --- /dev/null +++ b/redisinsight/api/src/common/utils/index.ts @@ -0,0 +1 @@ +export * from './certificate-import.util'; diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index afd6aa4340..ff4d26de4a 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -17,6 +17,10 @@ export default { INCORRECT_CREDENTIALS: (url) => `Could not connect to ${url}, please check the Username or Password.`, CA_CERT_EXIST: 'This ca certificate name is already in use.', + INVALID_CA_BODY: 'Invalid CA body', + INVALID_CERTIFICATE_BODY: 'Invalid certificate body', + INVALID_PRIVATE_KEY: 'Invalid private key', + CERTIFICATE_NAME_IS_NOT_DEFINED: 'Certificate name is not defined', CLIENT_CERT_EXIST: 'This client certificate name is already in use.', INVALID_CERTIFICATE_ID: 'Invalid certificate id.', SENTINEL_MASTER_NAME_REQUIRED: 'Sentinel master name must be specified.', diff --git a/redisinsight/api/src/modules/database-import/certificate-import.service.ts b/redisinsight/api/src/modules/database-import/certificate-import.service.ts new file mode 100644 index 0000000000..a5e30f212a --- /dev/null +++ b/redisinsight/api/src/modules/database-import/certificate-import.service.ts @@ -0,0 +1,216 @@ +import { Injectable } from '@nestjs/common'; +import { CaCertificate } from 'src/modules/certificate/models/ca-certificate'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; +import { Repository } from 'typeorm'; +import { EncryptionService } from 'src/modules/encryption/encryption.service'; +import { ModelEncryptor } from 'src/modules/encryption/model.encryptor'; +import { ClientCertificate } from 'src/modules/certificate/models/client-certificate'; +import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; +import { classToClass } from 'src/utils'; +import { + getCertNameFromFilename, + getPemBodyFromFileSync, + isValidPemCertificate, + isValidPemPrivateKey, +} from 'src/common/utils'; +import { + InvalidCaCertificateBodyException, InvalidCertificateNameException, + InvalidClientCertificateBodyException, InvalidClientPrivateKeyException, +} from 'src/modules/database-import/exceptions'; + +@Injectable() +export class CertificateImportService { + private caCertEncryptor: ModelEncryptor; + + private clientCertEncryptor: ModelEncryptor; + + constructor( + @InjectRepository(CaCertificateEntity) + private readonly caCertRepository: Repository, + @InjectRepository(ClientCertificateEntity) + private readonly clientCertRepository: Repository, + private readonly encryptionService: EncryptionService, + ) { + this.caCertEncryptor = new ModelEncryptor(encryptionService, ['certificate']); + this.clientCertEncryptor = new ModelEncryptor(encryptionService, ['certificate', 'key']); + } + + /** + * Validate data + prepare CA certificate to be imported along with new database + * @param cert + */ + async processCaCertificate(cert: Partial): Promise { + let toImport: Partial = { + certificate: null, + name: cert.name, + }; + + if (isValidPemCertificate(cert.certificate)) { + toImport.certificate = cert.certificate; + } else { + try { + toImport.certificate = getPemBodyFromFileSync(cert.certificate); + toImport.name = getCertNameFromFilename(cert.certificate); + } catch (e) { + // ignore error + toImport = null; + } + } + + if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) { + throw new InvalidCaCertificateBodyException(); + } + + if (!toImport?.name) { + throw new InvalidCertificateNameException(); + } + + return this.prepareCaCertificateForImport(toImport); + } + + /** + * Use existing certificate if found + * Generate unique name for new certificate + * @param cert + * @private + */ + private async prepareCaCertificateForImport(cert: Partial): Promise { + const encryptedModel = await this.caCertEncryptor.encryptEntity(cert as CaCertificate); + const existing = await this.caCertRepository.createQueryBuilder('c') + .select('c.id') + .where({ certificate: cert.certificate }) + .orWhere({ certificate: encryptedModel.certificate }) + .getOne(); + + if (existing) { + return existing; + } + + const name = await CertificateImportService.determineAvailableName( + cert.name, + this.caCertRepository, + ); + + return classToClass(CaCertificate, { + ...cert, + name, + }); + } + + /** + * Validate data + prepare CA certificate to be imported along with new database + * @param cert + */ + async processClientCertificate(cert: Partial): Promise { + const toImport: Partial = { + certificate: null, + key: null, + name: cert.name, + }; + + if (isValidPemCertificate(cert.certificate)) { + toImport.certificate = cert.certificate; + } else { + try { + toImport.certificate = getPemBodyFromFileSync(cert.certificate); + toImport.name = getCertNameFromFilename(cert.certificate); + } catch (e) { + // ignore error + toImport.certificate = null; + toImport.name = null; + } + } + + if (isValidPemPrivateKey(cert.key)) { + toImport.key = cert.key; + } else { + try { + toImport.key = getPemBodyFromFileSync(cert.key); + } catch (e) { + // ignore error + toImport.key = null; + } + } + + if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) { + throw new InvalidClientCertificateBodyException(); + } + + if (!toImport?.key || !isValidPemPrivateKey(toImport.key)) { + throw new InvalidClientPrivateKeyException(); + } + + if (!toImport?.name) { + throw new InvalidCertificateNameException(); + } + + return this.prepareClientCertificateForImport(toImport); + } + + /** + * Use existing certificate if found + * Generate unique name for new certificate + * @param cert + * @private + */ + private async prepareClientCertificateForImport(cert: Partial): Promise { + const encryptedModel = await this.clientCertEncryptor.encryptEntity(cert as ClientCertificate); + const existing = await this.clientCertRepository.createQueryBuilder('c') + .select('c.id') + .where({ + certificate: cert.certificate, + key: cert.key, + }) + .orWhere({ + certificate: encryptedModel.certificate, + key: encryptedModel.key, + }) + .getOne(); + + if (existing) { + return existing; + } + + const name = await CertificateImportService.determineAvailableName( + cert.name, + this.clientCertRepository, + ); + + return classToClass(ClientCertificate, { + ...cert, + name, + }); + } + + /** + * Find available name for certificate using such pattern "{N}_{name}" + * @param originalName + * @param repository + */ + static async determineAvailableName(originalName: string, repository: Repository): Promise { + let index = 0; + + // temporary solution + // investigate how to make working "regexp" for sqlite + // https://github.com/kriasoft/node-sqlite/issues/55 + // https://www.sqlite.org/c3ref/create_function.html + while (true) { + let name = originalName; + + if (index) { + name = `${index}_${name}`; + } + + if (!await repository + .createQueryBuilder('c') + .where({ name }) + .select(['c.id']) + .getOne()) { + return name; + } + + index += 1; + } + } +} diff --git a/redisinsight/api/src/modules/database-import/database-import.module.ts b/redisinsight/api/src/modules/database-import/database-import.module.ts index ae7e036f30..b4814589dc 100644 --- a/redisinsight/api/src/modules/database-import/database-import.module.ts +++ b/redisinsight/api/src/modules/database-import/database-import.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common'; import { DatabaseImportController } from 'src/modules/database-import/database-import.controller'; import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics'; +import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; @Module({ controllers: [DatabaseImportController], providers: [ DatabaseImportService, + CertificateImportService, DatabaseImportAnalytics, ], }) diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts index 56773f0890..472e01ae02 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -1,6 +1,7 @@ import { pick } from 'lodash'; import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; import { + mockCertificateImportService, mockDatabase, mockDatabaseImportAnalytics, mockDatabaseImportFile, @@ -14,9 +15,11 @@ import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { ValidationError } from 'class-validator'; import { - NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException, + NoDatabaseImportFileProvidedException, + SizeLimitExceededDatabaseImportFileException, UnableToParseDatabaseImportFileException, } from 'src/modules/database-import/exceptions'; +import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; describe('DatabaseImportService', () => { let service: DatabaseImportService; @@ -36,6 +39,10 @@ describe('DatabaseImportService', () => { create: jest.fn().mockResolvedValue(mockDatabase), })), }, + { + provide: CertificateImportService, + useFactory: mockCertificateImportService, + }, { provide: DatabaseImportAnalytics, useFactory: mockDatabaseImportAnalytics, @@ -154,6 +161,7 @@ describe('DatabaseImportService', () => { it('should create cluster database', async () => { await service['createDatabase']({ ...mockDatabase, + connectionType: undefined, cluster: true, }, 0); @@ -163,4 +171,64 @@ describe('DatabaseImportService', () => { }); }); }); + + describe('determineConnectionType', () => { + const tcs = [ + // common + { input: {}, output: ConnectionType.NOT_CONNECTED }, + // isCluster + { input: { isCluster: true }, output: ConnectionType.CLUSTER }, + { input: { isCluster: false }, output: ConnectionType.NOT_CONNECTED }, + { input: { isCluster: undefined }, output: ConnectionType.NOT_CONNECTED }, + // sentinelMasterName + { input: { sentinelMasterName: 'some name' }, output: ConnectionType.SENTINEL }, + // connectionType + { input: { connectionType: ConnectionType.STANDALONE }, output: ConnectionType.STANDALONE }, + { input: { connectionType: ConnectionType.CLUSTER }, output: ConnectionType.CLUSTER }, + { input: { connectionType: ConnectionType.SENTINEL }, output: ConnectionType.SENTINEL }, + { input: { connectionType: 'something not supported' }, output: ConnectionType.NOT_CONNECTED }, + // type + { input: { type: 'standalone' }, output: ConnectionType.STANDALONE }, + { input: { type: 'cluster' }, output: ConnectionType.CLUSTER }, + { input: { type: 'sentinel' }, output: ConnectionType.SENTINEL }, + { input: { type: 'something not supported' }, output: ConnectionType.NOT_CONNECTED }, + // priority tests + { + input: { + connectionType: ConnectionType.SENTINEL, + type: 'standalone', + isCluster: true, + sentinelMasterName: 'some name', + }, + output: ConnectionType.SENTINEL, + }, + { + input: { + type: 'standalone', + isCluster: true, + sentinelMasterName: 'some name', + }, + output: ConnectionType.STANDALONE, + }, + { + input: { + isCluster: true, + sentinelMasterName: 'some name', + }, + output: ConnectionType.CLUSTER, + }, + { + input: { + sentinelMasterName: 'some name', + }, + output: ConnectionType.SENTINEL, + }, + ]; + + tcs.forEach((tc) => { + it(`should return ${tc.output} when called with ${JSON.stringify(tc.input)}`, () => { + expect(DatabaseImportService.determineConnectionType(tc.input)).toEqual(tc.output); + }); + }); + }); }); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 234bd6e6ba..7d146edfe4 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -21,6 +21,7 @@ import { UnableToParseDatabaseImportFileException, } from 'src/modules/database-import/exceptions'; import { ValidationException } from 'src/common/exceptions'; +import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; @Injectable() export class DatabaseImportService { @@ -36,9 +37,24 @@ export class DatabaseImportService { ['port', ['port']], ['db', ['db']], ['isCluster', ['cluster']], + ['type', ['type']], + ['connectionType', ['connectionType']], + ['tls', ['tls', 'ssl']], + ['tlsServername', ['tlsServername']], + ['tlsCaName', ['caCert.name']], + ['tlsCaCert', ['caCert.certificate', 'caCert', 'sslOptions.ca', 'ssl_ca_cert_path']], + ['tlsClientName', ['clientCert.name']], + ['tlsClientCert', ['clientCert.certificate', 'certificate', 'sslOptions.cert', 'ssl_local_cert_path']], + ['tlsClientKey', ['clientCert.key', 'keyFile', 'sslOptions.key', 'ssl_private_key_path']], + ['sentinelMasterName', ['sentinelMaster.name', 'sentinelOptions.masterName', 'sentinelOptions.name']], + ['sentinelMasterUsername', ['sentinelMaster.username']], + ['sentinelMasterPassword', [ + 'sentinelMaster.password', 'sentinelOptions.nodePassword', 'sentinelOptions.sentinelPassword', + ]], ]; constructor( + private readonly certificateImportService: CertificateImportService, private readonly databaseRepository: DatabaseRepository, private readonly analytics: DatabaseImportAnalytics, ) {} @@ -115,6 +131,8 @@ export class DatabaseImportService { */ private async createDatabase(item: any, index: number): Promise { try { + let status = DatabaseImportStatus.Success; + const errors = []; const data: any = {}; this.fieldsMapSchema.forEach(([field, paths]) => { @@ -133,11 +151,45 @@ export class DatabaseImportService { data.name = `${data.host}:${data.port}`; } - // determine database type - if (data.isCluster) { - data.connectionType = ConnectionType.CLUSTER; - } else { - data.connectionType = ConnectionType.STANDALONE; + data.connectionType = DatabaseImportService.determineConnectionType(data); + + if (data?.sentinelMasterName) { + data.sentinelMaster = { + name: data.sentinelMasterName, + username: data.sentinelMasterUsername, + password: data.sentinelMasterPassword, + }; + data.nodes = [{ + host: data.host, + port: parseInt(data.port, 10), + }]; + } + + if (data?.tlsCaCert) { + try { + data.tls = true; + data.caCert = await this.certificateImportService.processCaCertificate({ + certificate: data.tlsCaCert, + name: data?.tlsCaName, + }); + } catch (e) { + status = DatabaseImportStatus.Partial; + errors.push(e); + } + } + + if (data?.tlsClientCert || data?.tlsClientKey) { + try { + data.tls = true; + data.clientCert = await this.certificateImportService.processClientCertificate({ + certificate: data.tlsClientCert, + key: data.tlsClientKey, + name: data?.tlsClientName, + }); + } catch (e) { + status = DatabaseImportStatus.Partial; + errors.push(e); + } } const dto = plainToClass( @@ -148,6 +200,9 @@ export class DatabaseImportService { acc[key] = data[key] === '' ? null : data[key]; return acc; }, {}), + { + groups: ['security'], + }, ); await this.validator.validateOrReject(dto, { @@ -160,9 +215,10 @@ export class DatabaseImportService { return { index, - status: DatabaseImportStatus.Success, + status, host: database.host, port: database.port, + errors: errors?.length ? errors : undefined, }; } catch (e) { let errors = [e]; @@ -195,6 +251,42 @@ export class DatabaseImportService { } } + /** + * Try to determine connection type based on input data + * Should return NOT_CONNECTED when it is not possible + * @param data + */ + static determineConnectionType(data: any = {}): ConnectionType { + if (data?.connectionType) { + return (data.connectionType in ConnectionType) + ? ConnectionType[data.connectionType] + : ConnectionType.NOT_CONNECTED; + } + + if (data?.type) { + switch (data.type) { + case 'cluster': + return ConnectionType.CLUSTER; + case 'sentinel': + return ConnectionType.SENTINEL; + case 'standalone': + return ConnectionType.STANDALONE; + default: + return ConnectionType.NOT_CONNECTED; + } + } + + if (data?.isCluster === true) { + return ConnectionType.CLUSTER; + } + + if (data?.sentinelMasterName) { + return ConnectionType.SENTINEL; + } + + return ConnectionType.NOT_CONNECTED; + } + /** * Try to parse file based on mimetype and known\supported formats * @param file diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index de696bcafc..e62ba1a3be 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -1,13 +1,19 @@ -import { PickType } from '@nestjs/swagger'; +import { ApiPropertyOptional, getSchemaPath, PickType } from '@nestjs/swagger'; import { Database } from 'src/modules/database/models/database'; import { Expose, Type } from 'class-transformer'; import { - IsInt, IsNotEmpty, Max, Min, + IsInt, IsNotEmpty, IsNotEmptyObject, IsOptional, Max, Min, ValidateNested, } from 'class-validator'; +import { caCertTransformer } from 'src/modules/certificate/transformers/ca-cert.transformer'; +import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto'; +import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto'; +import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.client-certificate.dto'; +import { clientCertTransformer } from 'src/modules/certificate/transformers/client-cert.transformer'; +import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; export class ImportDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', - 'connectionType', + 'connectionType', 'tls', 'verifyServerCert', 'sentinelMaster', 'nodes', ] as const) { @Expose() @IsNotEmpty() @@ -16,4 +22,25 @@ export class ImportDatabaseDto extends PickType(Database, [ @Min(0) @Max(65535) port: number; + + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(caCertTransformer) + @ValidateNested() + caCert?: CreateCaCertificateDto | UseCaCertificateDto; + + @ApiPropertyOptional({ + description: 'Client Certificate', + oneOf: [ + { $ref: getSchemaPath(CreateClientCertificateDto) }, + { $ref: getSchemaPath(UseCaCertificateDto) }, + ], + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(clientCertTransformer) + @ValidateNested() + clientCert?: CreateClientCertificateDto | UseClientCertificateDto; } diff --git a/redisinsight/api/src/modules/database-import/exceptions/index.ts b/redisinsight/api/src/modules/database-import/exceptions/index.ts index d62d9a0259..2ba9fdfb68 100644 --- a/redisinsight/api/src/modules/database-import/exceptions/index.ts +++ b/redisinsight/api/src/modules/database-import/exceptions/index.ts @@ -1,3 +1,7 @@ +export * from './invalid-ca-certificate-body.exception'; +export * from './invalid-client-certificate-body.exception'; +export * from './invalid-client-private-key.exception'; +export * from './invalid-certificate-name.exception'; export * from './size-limit-exceeded-database-import-file.exception'; export * from './no-database-import-file-provided.exception'; export * from './unable-to-parse-database-import-file.exception'; diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-ca-certificate-body.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-ca-certificate-body.exception.ts new file mode 100644 index 0000000000..fcffc334f4 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-ca-certificate-body.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidCaCertificateBodyException extends HttpException { + constructor(message: string = ERROR_MESSAGES.INVALID_CA_BODY) { + const response = { + message, + statusCode: 400, + error: 'Invalid Ca Certificate Body', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-certificate-name.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-certificate-name.exception.ts new file mode 100644 index 0000000000..4826f69c67 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-certificate-name.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidCertificateNameException extends HttpException { + constructor(message: string = ERROR_MESSAGES.CERTIFICATE_NAME_IS_NOT_DEFINED) { + const response = { + message, + statusCode: 400, + error: 'Invalid Certificate Name', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-client-certificate-body.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-certificate-body.exception.ts new file mode 100644 index 0000000000..6caa948b84 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-certificate-body.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidClientCertificateBodyException extends HttpException { + constructor(message: string = ERROR_MESSAGES.INVALID_CERTIFICATE_BODY) { + const response = { + message, + statusCode: 400, + error: 'Invalid Client Certificate Body', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-client-private-key.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-private-key.exception.ts new file mode 100644 index 0000000000..1b372ab8b3 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-private-key.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidClientPrivateKeyException extends HttpException { + constructor(message: string = ERROR_MESSAGES.INVALID_PRIVATE_KEY) { + const response = { + message, + statusCode: 400, + error: 'Invalid Client Private Key', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 932458daaa..be490865a1 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -9,6 +9,7 @@ import { ClientMetadata } from 'src/modules/redis/models/client-metadata'; import { DatabaseService } from 'src/modules/database/database.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; import { Database } from 'src/modules/database/models/database'; +import { ConnectionType } from 'src/modules/database/entities/database.entity'; @Injectable() export class DatabaseConnectionService { @@ -111,11 +112,29 @@ export class DatabaseConnectionService { const connectionName = generateRedisConnectionName(clientMetadata.namespace, clientMetadata.databaseId); try { - return await this.redisService.connectToDatabaseInstance( + const client = await this.redisService.connectToDatabaseInstance( database, clientMetadata.namespace, connectionName, ); + + if (database.connectionType === ConnectionType.NOT_CONNECTED) { + let connectionType = ConnectionType.STANDALONE; + + // cluster check + if (client.isCluster) { + connectionType = ConnectionType.CLUSTER; + } + + // sentinel check + if (client?.options?.['sentinels']?.length) { + connectionType = ConnectionType.SENTINEL; + } + + await this.repository.update(database.id, { connectionType }); + } + + return client; } catch (error) { this.logger.error('Failed to create database client', error); const exception = getRedisConnectionException( diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 179df10fe0..6d9b8decbd 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -21,6 +21,7 @@ export enum ConnectionType { STANDALONE = 'STANDALONE', CLUSTER = 'CLUSTER', SENTINEL = 'SENTINEL', + NOT_CONNECTED = 'NOT CONNECTED', } @Entity('database_instance') diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 8531689622..2abac76c3d 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -142,6 +142,8 @@ export class Database { type: Endpoint, isArray: true, }) + @IsOptional() + @Type(() => Endpoint) @Expose() nodes?: Endpoint[]; diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts index 94591a26ab..384a8ebcd4 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts @@ -3,6 +3,7 @@ import { BadRequestException } from '@nestjs/common'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { RedisService } from 'src/modules/redis/redis.service'; import { + mockDatabaseFactory, mockDatabaseInfoProvider, mockDatabaseService, mockIORedisClient, @@ -15,6 +16,7 @@ import { RedisSentinelService } from 'src/modules/redis-sentinel/redis-sentinel. import { RedisSentinelAnalytics } from 'src/modules/redis-sentinel/redis-sentinel.analytics'; import { DatabaseService } from 'src/modules/database/database.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; +import { DatabaseFactory } from 'src/modules/database/providers/database.factory'; describe('RedisSentinelService', () => { let service: RedisSentinelService; @@ -38,6 +40,10 @@ describe('RedisSentinelService', () => { provide: DatabaseService, useFactory: mockDatabaseService, }, + { + provide: DatabaseFactory, + useFactory: mockDatabaseFactory, + }, { provide: DatabaseInfoProvider, useFactory: mockDatabaseInfoProvider, diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts index 540d5a6292..2ae406f4f5 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts @@ -12,6 +12,7 @@ import { getRedisConnectionException } from 'src/utils'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { RedisSentinelAnalytics } from 'src/modules/redis-sentinel/redis-sentinel.analytics'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; +import { DatabaseFactory } from 'src/modules/database/providers/database.factory'; @Injectable() export class RedisSentinelService { @@ -20,6 +21,7 @@ export class RedisSentinelService { constructor( private readonly redisService: RedisService, private readonly databaseService: DatabaseService, + private readonly databaseFactory: DatabaseFactory, private readonly databaseInfoProvider: DatabaseInfoProvider, private readonly redisSentinelAnalytics: RedisSentinelAnalytics, ) {} @@ -113,7 +115,8 @@ export class RedisSentinelService { this.logger.log('Connection and getting sentinel masters.'); let result: SentinelMaster[]; try { - const client = await this.redisService.createStandaloneClient(dto, AppTool.Common, false); + const database = await this.databaseFactory.createStandaloneDatabaseModel(dto); + const client = await this.redisService.createStandaloneClient(database, AppTool.Common, false); result = await this.databaseInfoProvider.determineSentinelMasterGroups(client); this.redisSentinelAnalytics.sendGetSentinelMastersSucceedEvent(result); diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index 1780ad3b6e..4e845114de 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -195,12 +195,34 @@ export class RedisService { client = await this.createSentinelClient(database, nodes, tool, true, connectionName); break; default: - client = await this.createStandaloneClient(database, tool, true, connectionName); + // AUTO + client = await this.createClientAutomatically(database, tool, connectionName); } return client; } + public async createClientAutomatically(database: Database, tool: AppTool, connectionName) { + // try sentinel connection + if (database?.sentinelMaster) { + try { + return await this.createSentinelClient(database, database.nodes, tool, true, connectionName); + } catch (e) { + // ignore error + } + } + + // try cluster connection + try { + return await this.createClusterClient(database, database.nodes, true, connectionName); + } catch (e) { + // ignore error + } + + // Standalone in any other case + return this.createStandaloneClient(database, tool, true, connectionName); + } + public isClientConnected(client: Redis | Cluster): boolean { try { return client.status === 'ready';