Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions redisinsight/api/src/__mocks__/database-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mockCaCertificate, mockClientCertificate } from 'src/__mocks__/certific
import {
InvalidCaCertificateBodyException, InvalidCertificateNameException,
} from 'src/modules/database-import/exceptions';
import { mockSshOptionsPrivateKey } from 'src/__mocks__/ssh';

export const mockDatabasesToImportArray = new Array(10).fill(mockSentinelDatabaseWithTlsAuth);

Expand Down Expand Up @@ -87,3 +88,10 @@ export const mockCertificateImportService = jest.fn(() => ({
processCaCertificate: jest.fn().mockResolvedValue(mockCaCertificate),
processClientCertificate: jest.fn().mockResolvedValue(mockClientCertificate),
}));

export const mockSshImportService = jest.fn(() => ({
processSshOptions: jest.fn().mockResolvedValue({
...mockSshOptionsPrivateKey,
id: undefined,
}),
}));
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ 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 isValidSshPrivateKey = (cert: string): boolean => cert.startsWith('-----BEGIN OPENSSH PRIVATE KEY-----');
export const getPemBodyFromFileSync = (path: string): string => readFileSync(path).toString('utf8');
export const getCertNameFromFilename = (path: string): string => parse(path).name;
3 changes: 3 additions & 0 deletions redisinsight/api/src/constants/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export default {

CA_CERT_EXIST: 'This ca certificate name is already in use.',
INVALID_CA_BODY: 'Invalid CA body',
INVALID_SSH_PRIVATE_KEY_BODY: 'Invalid SSH private key body',
SSH_AGENTS_ARE_NOT_SUPPORTED: 'SSH Agents are not supported',
INVALID_SSH_BODY: 'Invalid SSH body',
INVALID_CERTIFICATE_BODY: 'Invalid certificate body',
INVALID_PRIVATE_KEY: 'Invalid private key',
CERTIFICATE_NAME_IS_NOT_DEFINED: 'Certificate name is not defined',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { DatabaseImportController } from 'src/modules/database-import/database-i
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';
import { SshImportService } from 'src/modules/database-import/ssh-import.service';

@Module({
controllers: [DatabaseImportController],
providers: [
DatabaseImportService,
CertificateImportService,
SshImportService,
DatabaseImportAnalytics,
],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
mockDatabaseImportAnalytics,
mockDatabaseImportFile,
mockDatabaseImportResponse,
mockSshImportService,
MockType,
} from 'src/__mocks__';
import { DatabaseRepository } from 'src/modules/database/repositories/database.repository';
Expand All @@ -18,9 +19,10 @@ import {
InvalidCaCertificateBodyException, InvalidCertificateNameException, InvalidClientCertificateBodyException,
NoDatabaseImportFileProvidedException,
SizeLimitExceededDatabaseImportFileException,
UnableToParseDatabaseImportFileException
UnableToParseDatabaseImportFileException,
} from 'src/modules/database-import/exceptions';
import { CertificateImportService } from 'src/modules/database-import/certificate-import.service';
import { SshImportService } from 'src/modules/database-import/ssh-import.service';

describe('DatabaseImportService', () => {
let service: DatabaseImportService;
Expand All @@ -45,6 +47,10 @@ describe('DatabaseImportService', () => {
provide: CertificateImportService,
useFactory: mockCertificateImportService,
},
{
provide: SshImportService,
useFactory: mockSshImportService,
},
{
provide: DatabaseImportAnalytics,
useFactory: mockDatabaseImportAnalytics,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from 'src/modules/database-import/exceptions';
import { ValidationException } from 'src/common/exceptions';
import { CertificateImportService } from 'src/modules/database-import/certificate-import.service';
import { SshImportService } from 'src/modules/database-import/ssh-import.service';

@Injectable()
export class DatabaseImportService {
Expand Down Expand Up @@ -51,10 +52,18 @@ export class DatabaseImportService {
['sentinelMasterPassword', [
'sentinelMaster.password', 'sentinelOptions.nodePassword', 'sentinelOptions.sentinelPassword',
]],
['sshHost', ['sshOptions.host', 'ssh_host', 'sshHost']],
['sshPort', ['sshOptions.port', 'ssh_port', 'sshPort']],
['sshUsername', ['sshOptions.username', 'ssh_user', 'sshUser']],
['sshPassword', ['sshOptions.password', 'ssh_password', 'sshPassword']],
['sshPrivateKey', ['sshOptions.privateKey', 'sshOptions.privatekey', 'ssh_private_key_path', 'sshKeyFile']],
['sshPassphrase', ['sshOptions.passphrase', 'sshKeyPassphrase']],
['sshAgentPath', ['ssh_agent_path']],
];

constructor(
private readonly certificateImportService: CertificateImportService,
private readonly sshImportService: SshImportService,
private readonly databaseRepository: DatabaseRepository,
private readonly analytics: DatabaseImportAnalytics,
) {}
Expand Down Expand Up @@ -159,7 +168,7 @@ export class DatabaseImportService {
if (data?.sentinelMasterName) {
data.sentinelMaster = {
name: data.sentinelMasterName,
username: data.sentinelMasterUsername,
username: data.sentinelMasterUsername || undefined,
password: data.sentinelMasterPassword,
};
data.nodes = [{
Expand All @@ -168,6 +177,17 @@ export class DatabaseImportService {
}];
}

if (data?.sshHost || data?.sshAgentPath) {
data.ssh = true;
try {
data.sshOptions = await this.sshImportService.processSshOptions(data);
} catch (e) {
status = DatabaseImportStatus.Partial;
data.ssh = false;
errors.push(e);
}
}

if (data?.tlsCaCert) {
try {
data.tls = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-
export class ImportDatabaseDto extends PickType(Database, [
'host', 'port', 'name', 'db', 'username', 'password',
'connectionType', 'tls', 'verifyServerCert', 'sentinelMaster', 'nodes',
'new',
'new', 'ssh', 'sshOptions',
] as const) {
@Expose()
@IsNotEmpty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ 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 './invalid-ssh-body.exception';
export * from './invalid-ssh-private-key-body.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';
export * from './ssh-agents-are-not-supported.exception';
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { HttpException } from '@nestjs/common';
import ERROR_MESSAGES from 'src/constants/error-messages';

export class InvalidSshBodyException extends HttpException {
constructor(message: string = ERROR_MESSAGES.INVALID_SSH_BODY) {
const response = {
message,
statusCode: 400,
error: 'Invalid SSH body',
};

super(response, 400);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { HttpException } from '@nestjs/common';
import ERROR_MESSAGES from 'src/constants/error-messages';

export class InvalidSshPrivateKeyBodyException extends HttpException {
constructor(message: string = ERROR_MESSAGES.INVALID_SSH_PRIVATE_KEY_BODY) {
const response = {
message,
statusCode: 400,
error: 'Invalid SSH Private Key Body',
};

super(response, 400);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { HttpException } from '@nestjs/common';
import ERROR_MESSAGES from 'src/constants/error-messages';

export class SshAgentsAreNotSupportedException extends HttpException {
constructor(message: string = ERROR_MESSAGES.SSH_AGENTS_ARE_NOT_SUPPORTED) {
const response = {
message,
statusCode: 400,
error: 'Ssh Agents Are Not Supported',
};

super(response, 400);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
mockSshOptionsBasic,
mockSshOptionsPrivateKey,
} from 'src/__mocks__';
import * as utils from 'src/common/utils';
import { Test, TestingModule } from '@nestjs/testing';
import { SshImportService } from 'src/modules/database-import/ssh-import.service';
import {
InvalidSshPrivateKeyBodyException,
InvalidSshBodyException,
SshAgentsAreNotSupportedException,
} from 'src/modules/database-import/exceptions';

jest.mock('src/common/utils', () => ({
...jest.requireActual('src/common/utils') as object,
getPemBodyFromFileSync: jest.fn(),
}));

const mockSshImportDataBasic = {
sshHost: mockSshOptionsBasic.host,
sshPort: mockSshOptionsBasic.port,
sshUsername: mockSshOptionsBasic.username,
sshPassword: mockSshOptionsBasic.password,
};

const mockSshImportDataPK = {
...mockSshImportDataBasic,
sshPrivateKey: mockSshOptionsPrivateKey.privateKey,
sshPassphrase: mockSshOptionsPrivateKey.passphrase,
};

describe('SshImportService', () => {
let service: SshImportService;

beforeEach(async () => {
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
providers: [
SshImportService,
],
}).compile();

service = await module.get(SshImportService);
});

let getPemBodyFromFileSyncSpy;

describe('processSshOptions', () => {
beforeEach(() => {
getPemBodyFromFileSyncSpy = jest.spyOn(utils as any, 'getPemBodyFromFileSync');
getPemBodyFromFileSyncSpy.mockReturnValue(mockSshOptionsPrivateKey.privateKey);
});

it('should successfully process ssh basic', async () => {
const response = await service.processSshOptions({
...mockSshImportDataBasic,
});

expect(response).toEqual({
...mockSshOptionsBasic,
id: undefined,
privateKey: undefined,
passphrase: undefined,
});
});

it('should successfully process ssh PKP', async () => {
const response = await service.processSshOptions({
...mockSshImportDataPK,
});

expect(response).toEqual({
...mockSshOptionsPrivateKey,
id: undefined,
password: undefined,
});
});

it('should successfully process ssh PKP (from path)', async () => {
const response = await service.processSshOptions({
...mockSshImportDataPK,
sshPrivateKey: '/some/path',
});

expect(response).toEqual({
...mockSshOptionsPrivateKey,
id: undefined,
password: undefined,
});
});

it('should throw an error when invalid privateKey body provided', async () => {
getPemBodyFromFileSyncSpy.mockImplementation(() => { throw new Error('no file'); });

try {
await service.processSshOptions({
...mockSshImportDataPK,
sshPrivateKey: '/some/path',
});
} catch (e) {
expect(e).toBeInstanceOf(InvalidSshPrivateKeyBodyException);
}
});

it('should throw an error when ssh agent provided', async () => {
try {
await service.processSshOptions({
...mockSshImportDataPK,
sshAgentPath: '/agent/path',
});
} catch (e) {
expect(e).toBeInstanceOf(SshAgentsAreNotSupportedException);
}
});
});

it('should throw an error when no username defined', async () => {
try {
await service.processSshOptions({
...mockSshImportDataPK,
sshUsername: undefined,
});
} catch (e) {
expect(e).toBeInstanceOf(InvalidSshBodyException);
}
});

it('should throw an error when no port defined', async () => {
try {
await service.processSshOptions({
...mockSshImportDataPK,
sshPassword: undefined,
});
} catch (e) {
expect(e).toBeInstanceOf(InvalidSshBodyException);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { isUndefined } from 'lodash';
import {
getPemBodyFromFileSync,
isValidSshPrivateKey,
} from 'src/common/utils';
import {
InvalidSshPrivateKeyBodyException, InvalidSshBodyException, SshAgentsAreNotSupportedException,
} from 'src/modules/database-import/exceptions';
import { SshOptions } from 'src/modules/ssh/models/ssh-options';

@Injectable()
export class SshImportService {
/**
* Validate data + prepare CA certificate to be imported along with new database
* @param data
*/
async processSshOptions(data: any): Promise<Partial<SshOptions>> {
let sshOptions: Partial<SshOptions> = {
host: data.sshHost,
};

if (isUndefined(data.sshPort) || isUndefined(data.sshUsername)) {
throw new InvalidSshBodyException();
} else {
sshOptions.port = parseInt(data.sshPort, 10);
sshOptions.username = data.sshUsername;
}

if (data.sshPrivateKey) {
sshOptions.passphrase = data.sshPassphrase || data.sshPassword || null;

if (isValidSshPrivateKey(data.sshPrivateKey)) {
sshOptions.privateKey = data.sshPrivateKey;
} else {
try {
sshOptions.privateKey = getPemBodyFromFileSync(data.sshPrivateKey);
} catch (e) {
// ignore error
sshOptions = null;
}
}
} else {
sshOptions.password = data.sshPassword || null;
}

if (!sshOptions || (sshOptions?.privateKey && !isValidSshPrivateKey(sshOptions.privateKey))) {
throw new InvalidSshPrivateKeyBodyException();
}

if (data.sshAgentPath) {
throw new SshAgentsAreNotSupportedException();
}

return sshOptions;
}
}
Loading