refactor(core): Store SSH key pair for source control in DB settings (#8965)

This commit is contained in:
Iván Ovejero 2024-03-26 19:09:46 +01:00 committed by GitHub
parent 19d9e71cb9
commit ddc0f57116
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 201 additions and 33 deletions

View file

@ -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';`);
}
}

View file

@ -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,
@ -109,4 +110,5 @@ export const mysqlMigrations: Migration[] = [
AddGlobalAdminRole1700571993961,
DropRoleMapping1705429061930,
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
];

View file

@ -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,
@ -107,4 +108,5 @@ export const postgresMigrations: Migration[] = [
AddGlobalAdminRole1700571993961,
DropRoleMapping1705429061930,
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
];

View file

@ -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,
@ -103,6 +104,7 @@ const sqliteMigrations: Migration[] = [
AddGlobalAdminRole1700571993961,
DropRoleMapping1705429061930,
RemoveFailedExecutionStatus1711018413374,
MoveSshKeysToDatabase1711390882123,
];
export { sqliteMigrations };

View file

@ -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] })
@ -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);
}

View file

@ -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;
@ -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;

View file

@ -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 {
@ -33,6 +34,7 @@ export class SourceControlGitService {
constructor(
private readonly logger: Logger,
private readonly ownershipService: OwnershipService,
private readonly sourceControlPreferencesService: SourceControlPreferencesService,
) {}
/**
@ -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;
@ -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,

View file

@ -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,
@ -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);
@ -46,7 +48,6 @@ export class SourceControlPreferencesService {
return {
...this._sourceControlPreferences,
connected: this._sourceControlPreferences.connected ?? false,
publicKey: this.getPublicKey(),
};
}
@ -66,24 +67,71 @@ export class SourceControlPreferencesService {
);
}
getPublicKey(): string {
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 this.sshKeyName; // fall back to key in filesystem
}
async getPublicKey() {
try {
return fsReadFileSync(this.sshKeyName + '.pub', { encoding: 'utf8' });
} catch (error) {
this.logger.error(`Failed to read public key: ${(error as Error).message}`);
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 '';
}
hasKeyPairFiles(): boolean {
return fsExistsSync(this.sshKeyName) && fsExistsSync(this.sshKeyName + '.pub');
}
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}`);
}
}
@ -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();
}
@ -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);