mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
refactor(core): Store SSH key pair for source control in DB settings (#8965)
This commit is contained in:
parent
19d9e71cb9
commit
ddc0f57116
|
@ -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';`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue