Skip to content

Commit

Permalink
refactor(core): Store SSH key pair for source control in DB settings (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
ivov authored Mar 26, 2024
1 parent 19d9e71 commit ddc0f57
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 33 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import path from 'node:path';
import { readFile, writeFile, rm } from 'node:fs/promises';
import Container from 'typedi';
import { Cipher, InstanceSettings } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import type { MigrationContext, ReversibleMigration } from '@db/types';

/**
* Move SSH key pair from file system to database, to enable SSH connections
* when running n8n in multiple containers - mains, webhooks, workers, etc.
*/
export class MoveSshKeysToDatabase1711390882123 implements ReversibleMigration {
private readonly settingsKey = 'features.sourceControl.sshKeys';

private readonly privateKeyPath = path.join(
Container.get(InstanceSettings).n8nFolder,
'ssh',
'key',
);

private readonly publicKeyPath = this.privateKeyPath + '.pub';

private readonly cipher = Container.get(Cipher);

async up({ escape, runQuery, logger, migrationName }: MigrationContext) {
let privateKey, publicKey;

try {
[privateKey, publicKey] = await Promise.all([
readFile(this.privateKeyPath, { encoding: 'utf8' }),
readFile(this.publicKeyPath, { encoding: 'utf8' }),
]);
} catch {
logger.info(`[${migrationName}] No SSH keys in filesystem, skipping`);
return;
}

const settings = escape.tableName('settings');

const rows: Array<{ value: string }> = await runQuery(
`SELECT value FROM ${settings} WHERE key = '${this.settingsKey}';`,
);

if (rows.length === 1) {
logger.info(`[${migrationName}] SSH keys already in database, skipping`);
return;
}

const value = JSON.stringify({
encryptedPrivateKey: this.cipher.encrypt(privateKey),
publicKey,
});

await runQuery(
`INSERT INTO ${settings} (key, value) VALUES ('${this.settingsKey}', '${value}');`,
);

try {
await Promise.all([rm(this.privateKeyPath), rm(this.publicKeyPath)]);
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
logger.error(
`[${migrationName}] Failed to remove SSH keys from filesystem: ${error.message}`,
);
}
}

async down({ escape, runQuery, logger, migrationName }: MigrationContext) {
const settings = escape.tableName('settings');

const rows: Array<{ value: string }> = await runQuery(
`SELECT value FROM ${settings} WHERE key = '${this.settingsKey}';`,
);

if (rows.length !== 1) {
logger.info(`[${migrationName}] No SSH keys in database, skipping revert`);
return;
}

const [row] = rows;

type KeyPair = { publicKey: string; encryptedPrivateKey: string };

const dbKeyPair = jsonParse<KeyPair | null>(row.value, { fallbackValue: null });

if (!dbKeyPair) {
logger.info(`[${migrationName}] Malformed SSH keys in database, skipping revert`);
return;
}

const privateKey = this.cipher.decrypt(dbKeyPair.encryptedPrivateKey);
const { publicKey } = dbKeyPair;

try {
await Promise.all([
writeFile(this.privateKeyPath, privateKey, { encoding: 'utf8', mode: 0o600 }),
writeFile(this.publicKeyPath, publicKey, { encoding: 'utf8', mode: 0o600 }),
]);
} catch {
logger.error(`[${migrationName}] Failed to write SSH keys to filesystem, skipping revert`);
return;
}

await runQuery(`DELETE ${settings} WHERE WHERE key = 'features.sourceControl.sshKeys';`);
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/databases/migrations/mysqldb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';

export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
Expand Down Expand Up @@ -109,4 +110,5 @@ export const mysqlMigrations: Migration[] = [
AddGlobalAdminRole1700571993961,
DropRoleMapping1705429061930,
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
];
2 changes: 2 additions & 0 deletions packages/cli/src/databases/migrations/postgresdb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { DropRoleMapping1705429061930 } from '../common/1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';

export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
Expand Down Expand Up @@ -107,4 +108,5 @@ export const postgresMigrations: Migration[] = [
AddGlobalAdminRole1700571993961,
DropRoleMapping1705429061930,
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
];
2 changes: 2 additions & 0 deletions packages/cli/src/databases/migrations/sqlite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { ModifyWorkflowHistoryNodesAndConnections1695829275184 } from '../common
import { AddGlobalAdminRole1700571993961 } from '../common/1700571993961-AddGlobalAdminRole';
import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
import { RemoveFailedExecutionStatus1711018413374 } from '../common/1711018413374-RemoveFailedExecutionStatus';
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';

const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
Expand Down Expand Up @@ -103,6 +104,7 @@ const sqliteMigrations: Migration[] = [
AddGlobalAdminRole1700571993961,
DropRoleMapping1705429061930,
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
];

export { sqliteMigrations };
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export class SourceControlController {
@Get('/preferences', { middlewares: [sourceControlLicensedMiddleware], skipAuth: true })
async getPreferences(): Promise<SourceControlPreferences> {
// returns the settings with the privateKey property redacted
return this.sourceControlPreferencesService.getPreferences();
const publicKey = await this.sourceControlPreferencesService.getPublicKey();
return { ...this.sourceControlPreferencesService.getPreferences(), publicKey };
}

@Post('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
Expand Down Expand Up @@ -238,7 +239,8 @@ export class SourceControlController {
try {
const keyPairType = req.body.keyGeneratorType;
const result = await this.sourceControlPreferencesService.generateAndSaveKeyPair(keyPairType);
return result;
const publicKey = await this.sourceControlPreferencesService.getPublicKey();
return { ...result, publicKey };
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { ApplicationError } from 'n8n-workflow';

@Service()
export class SourceControlService {
/** Path to SSH private key in filesystem. */
private sshKeyName: string;

private sshFolder: string;
Expand Down Expand Up @@ -112,7 +113,7 @@ export class SourceControlService {
});
await this.sourceControlExportService.deleteRepositoryFolder();
if (!options.keepKeyPair) {
await this.sourceControlPreferencesService.deleteKeyPairFiles();
await this.sourceControlPreferencesService.deleteKeyPair();
}
this.gitService.resetService();
return this.sourceControlPreferencesService.sourceControlPreferences;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { User } from '@db/entities/User';
import { Logger } from '@/Logger';
import { ApplicationError } from 'n8n-workflow';
import { OwnershipService } from '@/services/ownership.service';
import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee';

@Service()
export class SourceControlGitService {
Expand All @@ -33,6 +34,7 @@ export class SourceControlGitService {
constructor(
private readonly logger: Logger,
private readonly ownershipService: OwnershipService,
private readonly sourceControlPreferencesService: SourceControlPreferencesService,
) {}

/**
Expand Down Expand Up @@ -66,12 +68,7 @@ export class SourceControlGitService {
sshFolder: string;
sshKeyName: string;
}): Promise<void> {
const {
sourceControlPreferences: sourceControlPreferences,
gitFolder,
sshKeyName,
sshFolder,
} = options;
const { sourceControlPreferences: sourceControlPreferences, gitFolder, sshFolder } = options;
this.logger.debug('GitService.init');
if (this.git !== null) {
return;
Expand All @@ -82,8 +79,10 @@ export class SourceControlGitService {

sourceControlFoldersExistCheck([gitFolder, sshFolder]);

const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath();

const sshKnownHosts = path.join(sshFolder, 'known_hosts');
const sshCommand = `ssh -o UserKnownHostsFile=${sshKnownHosts} -o StrictHostKeyChecking=no -i ${sshKeyName}`;
const sshCommand = `ssh -o UserKnownHostsFile=${sshKnownHosts} -o StrictHostKeyChecking=no -i ${privateKeyPath}`;

this.gitOptions = {
baseDir: gitFolder,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import os from 'node:os';
import { writeFile, chmod, readFile } from 'node:fs/promises';
import Container, { Service } from 'typedi';
import { SourceControlPreferences } from './types/sourceControlPreferences';
import type { ValidationError } from 'class-validator';
import { validate } from 'class-validator';
import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs';
import { writeFile as fsWriteFile, rm as fsRm } from 'fs/promises';
import {
generateSshKeyPair,
isSourceControlLicensed,
sourceControlFoldersExistCheck,
} from './sourceControlHelper.ee';
import { InstanceSettings } from 'n8n-core';
import { Cipher, InstanceSettings } from 'n8n-core';
import { ApplicationError, jsonParse } from 'n8n-workflow';
import {
SOURCE_CONTROL_SSH_FOLDER,
Expand All @@ -36,6 +37,7 @@ export class SourceControlPreferencesService {
constructor(
instanceSettings: InstanceSettings,
private readonly logger: Logger,
private readonly cipher: Cipher,
) {
this.sshFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_SSH_FOLDER);
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
Expand All @@ -46,7 +48,6 @@ export class SourceControlPreferencesService {
return {
...this._sourceControlPreferences,
connected: this._sourceControlPreferences.connected ?? false,
publicKey: this.getPublicKey(),
};
}

Expand All @@ -66,24 +67,71 @@ export class SourceControlPreferencesService {
);
}

getPublicKey(): string {
try {
return fsReadFileSync(this.sshKeyName + '.pub', { encoding: 'utf8' });
} catch (error) {
this.logger.error(`Failed to read public key: ${(error as Error).message}`);
private async getKeyPairFromDatabase() {
const dbSetting = await Container.get(SettingsRepository).findByKey(
'features.sourceControl.sshKeys',
);

if (!dbSetting?.value) return null;

type KeyPair = { publicKey: string; encryptedPrivateKey: string };

return jsonParse<KeyPair | null>(dbSetting.value, { fallbackValue: null });
}

private async getPrivateKeyFromDatabase() {
const dbKeyPair = await this.getKeyPairFromDatabase();

if (!dbKeyPair) return null;

return this.cipher.decrypt(dbKeyPair.encryptedPrivateKey);
}

private async getPublicKeyFromDatabase() {
const dbKeyPair = await this.getKeyPairFromDatabase();

if (!dbKeyPair) return null;

return dbKeyPair.publicKey;
}

async getPrivateKeyPath() {
const dbPrivateKey = await this.getPrivateKeyFromDatabase();

if (dbPrivateKey) {
const tempFilePath = path.join(os.tmpdir(), 'ssh_private_key_temp');

await writeFile(tempFilePath, dbPrivateKey);

await chmod(tempFilePath, 0o600);

return tempFilePath;
}
return '';

return this.sshKeyName; // fall back to key in filesystem
}

hasKeyPairFiles(): boolean {
return fsExistsSync(this.sshKeyName) && fsExistsSync(this.sshKeyName + '.pub');
async getPublicKey() {
try {
const dbPublicKey = await this.getPublicKeyFromDatabase();

if (dbPublicKey) return dbPublicKey;

return await readFile(this.sshKeyName + '.pub', { encoding: 'utf8' });
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
this.logger.error(`Failed to read SSH public key: ${error.message}`);
}
return '';
}

async deleteKeyPairFiles(): Promise<void> {
async deleteKeyPair() {
try {
await fsRm(this.sshFolder, { recursive: true });
} catch (error) {
this.logger.error(`Failed to delete ssh folder: ${(error as Error).message}`);
await Container.get(SettingsRepository).delete({ key: 'features.sourceControl.sshKeys' });
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
this.logger.error(`Failed to delete SSH key pair: ${error.message}`);
}
}

Expand All @@ -108,13 +156,27 @@ export class SourceControlPreferencesService {
});
await fsWriteFile(this.sshKeyName, keyPair.privateKey, { encoding: 'utf8', mode: 0o600 });
} catch (error) {
throw new ApplicationError('Failed to save key pair', { cause: error });
throw new ApplicationError('Failed to save key pair to disk', { cause: error });
}
}
// update preferences only after generating key pair to prevent endless loop
if (keyPairType !== this.getPreferences().keyGeneratorType) {
await this.setPreferences({ keyGeneratorType: keyPairType });
}

try {
await Container.get(SettingsRepository).save({
key: 'features.sourceControl.sshKeys',
value: JSON.stringify({
encryptedPrivateKey: this.cipher.encrypt(keyPair.privateKey),
publicKey: keyPair.publicKey,
}),
loadOnStartup: true,
});
} catch (error) {
throw new ApplicationError('Failed to write key pair to database', { cause: error });
}

return this.getPreferences();
}

Expand Down Expand Up @@ -161,14 +223,6 @@ export class SourceControlPreferencesService {
preferences: Partial<SourceControlPreferences>,
saveToDb = true,
): Promise<SourceControlPreferences> {
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
if (!this.hasKeyPairFiles()) {
const keyPairType =
preferences.keyGeneratorType ??
(config.get('sourceControl.defaultKeyPairType') as KeyPairType);
this.logger.debug(`No key pair files found, generating new pair using type: ${keyPairType}`);
await this.generateAndSaveKeyPair(keyPairType);
}
this.sourceControlPreferences = preferences;
if (saveToDb) {
const settingsValue = JSON.stringify(this._sourceControlPreferences);
Expand Down

0 comments on commit ddc0f57

Please sign in to comment.