mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat(core): Add rsa option to ssh key generation (#7154)
PR adds a new field to the SourceControlPreferences as well as to the POST parameters for the `source-control/preferences` and `source-control/generate-key-pair` endpoints. Both now accept an optional string parameter `keyGeneratorType` of `'ed25519' | 'rsa'` Calling the `source-control/generate-key-pair` endpoint with the parameter set, it will also update the stored preferences accordingly (so that in the future new keys will use the same method) By default ed25519 is being used. The default may be changed using a new environment parameter: `N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE` which can be `rsa` or `ed25519` RSA keys are generated with a length of 4096 bytes.
This commit is contained in:
parent
aaf87c3edd
commit
fdac2c8572
|
@ -1204,4 +1204,13 @@ export const schema = {
|
||||||
env: 'N8N_AI_ENABLED',
|
env: 'N8N_AI_ENABLED',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sourceControl: {
|
||||||
|
defaultKeyPairType: {
|
||||||
|
doc: 'Default SSH key type to use when generating SSH keys',
|
||||||
|
format: ['rsa', 'ed25519'] as const,
|
||||||
|
default: 'ed25519',
|
||||||
|
env: 'N8N_SOURCECONTROL_DEFAULT_SSH_KEY_TYPE',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -118,7 +118,7 @@ export class SourceControlController {
|
||||||
) {
|
) {
|
||||||
await this.sourceControlService.setBranch(sanitizedPreferences.branchName);
|
await this.sourceControlService.setBranch(sanitizedPreferences.branchName);
|
||||||
}
|
}
|
||||||
if (sanitizedPreferences.branchColor || sanitizedPreferences.branchReadOnly !== undefined) {
|
if (sanitizedPreferences.branchColor ?? sanitizedPreferences.branchReadOnly !== undefined) {
|
||||||
await this.sourceControlPreferencesService.setPreferences(
|
await this.sourceControlPreferencesService.setPreferences(
|
||||||
{
|
{
|
||||||
branchColor: sanitizedPreferences.branchColor,
|
branchColor: sanitizedPreferences.branchColor,
|
||||||
|
@ -237,9 +237,12 @@ export class SourceControlController {
|
||||||
|
|
||||||
@Authorized(['global', 'owner'])
|
@Authorized(['global', 'owner'])
|
||||||
@Post('/generate-key-pair', { middlewares: [sourceControlLicensedMiddleware] })
|
@Post('/generate-key-pair', { middlewares: [sourceControlLicensedMiddleware] })
|
||||||
async generateKeyPair(): Promise<SourceControlPreferences> {
|
async generateKeyPair(
|
||||||
|
req: SourceControlRequest.GenerateKeyPair,
|
||||||
|
): Promise<SourceControlPreferences> {
|
||||||
try {
|
try {
|
||||||
const result = await this.sourceControlPreferencesService.generateAndSaveKeyPair();
|
const keyPairType = req.body.keyGeneratorType;
|
||||||
|
const result = await this.sourceControlPreferencesService.generateAndSaveKeyPair(keyPairType);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BadRequestError((error as { message: string }).message);
|
throw new BadRequestError((error as { message: string }).message);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import type { SourceControlledFile } from './types/sourceControlledFile';
|
import type { SourceControlledFile } from './types/sourceControlledFile';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import type { KeyPairType } from './types/keyPairType';
|
||||||
|
|
||||||
export function stringContainsExpression(testString: string): boolean {
|
export function stringContainsExpression(testString: string): boolean {
|
||||||
return /^=.*\{\{.*\}\}/.test(testString);
|
return /^=.*\{\{.*\}\}/.test(testString);
|
||||||
|
@ -63,7 +64,7 @@ export function isSourceControlLicensed() {
|
||||||
return license.isSourceControlLicensed();
|
return license.isSourceControlLicensed();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
|
export async function generateSshKeyPair(keyType: KeyPairType) {
|
||||||
const sshpk = await import('sshpk');
|
const sshpk = await import('sshpk');
|
||||||
const keyPair: KeyPair = {
|
const keyPair: KeyPair = {
|
||||||
publicKey: '',
|
publicKey: '',
|
||||||
|
|
|
@ -19,6 +19,8 @@ import {
|
||||||
SOURCE_CONTROL_PREFERENCES_DB_KEY,
|
SOURCE_CONTROL_PREFERENCES_DB_KEY,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import type { KeyPairType } from './types/keyPairType';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SourceControlPreferencesService {
|
export class SourceControlPreferencesService {
|
||||||
|
@ -86,9 +88,15 @@ export class SourceControlPreferencesService {
|
||||||
* Will generate an ed25519 key pair and save it to the database and the file system
|
* Will generate an ed25519 key pair and save it to the database and the file system
|
||||||
* Note: this will overwrite any existing key pair
|
* Note: this will overwrite any existing key pair
|
||||||
*/
|
*/
|
||||||
async generateAndSaveKeyPair(): Promise<SourceControlPreferences> {
|
async generateAndSaveKeyPair(keyPairType?: KeyPairType): Promise<SourceControlPreferences> {
|
||||||
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
|
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
|
||||||
const keyPair = await generateSshKeyPair('ed25519');
|
if (!keyPairType) {
|
||||||
|
keyPairType =
|
||||||
|
this.getPreferences().keyGeneratorType ??
|
||||||
|
(config.get('sourceControl.defaultKeyPairType') as KeyPairType) ??
|
||||||
|
'ed25519';
|
||||||
|
}
|
||||||
|
const keyPair = await generateSshKeyPair(keyPairType);
|
||||||
if (keyPair.publicKey && keyPair.privateKey) {
|
if (keyPair.publicKey && keyPair.privateKey) {
|
||||||
try {
|
try {
|
||||||
await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, {
|
await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, {
|
||||||
|
@ -100,6 +108,10 @@ export class SourceControlPreferencesService {
|
||||||
throw Error(`Failed to save key pair: ${(error as Error).message}`);
|
throw Error(`Failed to save key pair: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// update preferences only after generating key pair to prevent endless loop
|
||||||
|
if (keyPairType !== this.getPreferences().keyGeneratorType) {
|
||||||
|
await this.setPreferences({ keyGeneratorType: keyPairType });
|
||||||
|
}
|
||||||
return this.getPreferences();
|
return this.getPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,8 +158,11 @@ export class SourceControlPreferencesService {
|
||||||
): Promise<SourceControlPreferences> {
|
): Promise<SourceControlPreferences> {
|
||||||
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
|
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
|
||||||
if (!this.hasKeyPairFiles()) {
|
if (!this.hasKeyPairFiles()) {
|
||||||
LoggerProxy.debug('No key pair files found, generating new pair');
|
const keyPairType =
|
||||||
await this.generateAndSaveKeyPair();
|
preferences.keyGeneratorType ??
|
||||||
|
(config.get('sourceControl.defaultKeyPairType') as KeyPairType);
|
||||||
|
LoggerProxy.debug(`No key pair files found, generating new pair using type: ${keyPairType}`);
|
||||||
|
await this.generateAndSaveKeyPair(keyPairType);
|
||||||
}
|
}
|
||||||
this.sourceControlPreferences = preferences;
|
this.sourceControlPreferences = preferences;
|
||||||
if (saveToDb) {
|
if (saveToDb) {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export type KeyPairType = 'ed25519' | 'rsa';
|
|
@ -9,6 +9,7 @@ import type { SourceControlPullWorkFolder } from './sourceControlPullWorkFolder'
|
||||||
import type { SourceControlDisconnect } from './sourceControlDisconnect';
|
import type { SourceControlDisconnect } from './sourceControlDisconnect';
|
||||||
import type { SourceControlSetReadOnly } from './sourceControlSetReadOnly';
|
import type { SourceControlSetReadOnly } from './sourceControlSetReadOnly';
|
||||||
import type { SourceControlGetStatus } from './sourceControlGetStatus';
|
import type { SourceControlGetStatus } from './sourceControlGetStatus';
|
||||||
|
import type { SourceControlGenerateKeyPair } from './sourceControlGenerateKeyPair';
|
||||||
|
|
||||||
export declare namespace SourceControlRequest {
|
export declare namespace SourceControlRequest {
|
||||||
type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial<SourceControlPreferences>, {}>;
|
type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial<SourceControlPreferences>, {}>;
|
||||||
|
@ -21,4 +22,5 @@ export declare namespace SourceControlRequest {
|
||||||
type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>;
|
type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>;
|
||||||
type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>;
|
type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>;
|
||||||
type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>;
|
type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>;
|
||||||
|
type GenerateKeyPair = AuthenticatedRequest<{}, {}, SourceControlGenerateKeyPair, {}>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import { KeyPairType } from './keyPairType';
|
||||||
|
|
||||||
|
export class SourceControlGenerateKeyPair {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly keyGeneratorType?: KeyPairType;
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator';
|
import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { KeyPairType } from './keyPairType';
|
||||||
|
|
||||||
export class SourceControlPreferences {
|
export class SourceControlPreferences {
|
||||||
constructor(preferences: Partial<SourceControlPreferences> | undefined = undefined) {
|
constructor(preferences: Partial<SourceControlPreferences> | undefined = undefined) {
|
||||||
|
@ -28,6 +29,10 @@ export class SourceControlPreferences {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
readonly initRepo?: boolean;
|
readonly initRepo?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly keyGeneratorType?: KeyPairType;
|
||||||
|
|
||||||
static fromJSON(json: Partial<SourceControlPreferences>): SourceControlPreferences {
|
static fromJSON(json: Partial<SourceControlPreferences>): SourceControlPreferences {
|
||||||
return new SourceControlPreferences(json);
|
return new SourceControlPreferences(json);
|
||||||
}
|
}
|
||||||
|
@ -42,6 +47,7 @@ export class SourceControlPreferences {
|
||||||
branchName: preferences.branchName ?? defaultPreferences.branchName,
|
branchName: preferences.branchName ?? defaultPreferences.branchName,
|
||||||
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
|
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
|
||||||
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,
|
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,
|
||||||
|
keyGeneratorType: preferences.keyGeneratorType ?? defaultPreferences.keyGeneratorType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import * as utils from '../shared/utils/';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper';
|
import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
import config from '@/config';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee';
|
import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee';
|
||||||
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
|
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
|
||||||
|
@ -69,4 +70,21 @@ describe('GET /sourceControl/preferences', () => {
|
||||||
expect(data[0].id).toBe('haQetoXq9GxHSkft');
|
expect(data[0].id).toBe('haQetoXq9GxHSkft');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refreshing key pairsshould return new rsa key', async () => {
|
||||||
|
config.set('sourceControl.defaultKeyPairType', 'rsa');
|
||||||
|
await authOwnerAgent
|
||||||
|
.post(`/${SOURCE_CONTROL_API_ROOT}/generate-key-pair`)
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(
|
||||||
|
Container.get(SourceControlPreferencesService).getPreferences().keyGeneratorType,
|
||||||
|
).toBe('rsa');
|
||||||
|
expect(res.body.data).toHaveProperty('publicKey');
|
||||||
|
expect(res.body.data).toHaveProperty('keyGeneratorType');
|
||||||
|
expect(res.body.data.keyGeneratorType).toBe('rsa');
|
||||||
|
expect(res.body.data.publicKey).toContain('ssh-rsa');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { LoggerProxy } from 'n8n-workflow';
|
||||||
import { getLogger } from '@/Logger';
|
import { getLogger } from '@/Logger';
|
||||||
import { constants as fsConstants, accessSync } from 'fs';
|
import { constants as fsConstants, accessSync } from 'fs';
|
||||||
import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile';
|
import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile';
|
||||||
|
import type { SourceControlPreferences } from '@/environments/sourceControl/types/sourceControlPreferences';
|
||||||
|
|
||||||
const pushResult: SourceControlledFile[] = [
|
const pushResult: SourceControlledFile[] = [
|
||||||
{
|
{
|
||||||
|
@ -167,13 +168,21 @@ beforeAll(async () => {
|
||||||
|
|
||||||
describe('Source Control', () => {
|
describe('Source Control', () => {
|
||||||
it('should generate an SSH key pair', async () => {
|
it('should generate an SSH key pair', async () => {
|
||||||
const keyPair = await generateSshKeyPair();
|
const keyPair = await generateSshKeyPair('ed25519');
|
||||||
expect(keyPair.privateKey).toBeTruthy();
|
expect(keyPair.privateKey).toBeTruthy();
|
||||||
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
|
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||||
expect(keyPair.publicKey).toBeTruthy();
|
expect(keyPair.publicKey).toBeTruthy();
|
||||||
expect(keyPair.publicKey).toContain('ssh-ed25519');
|
expect(keyPair.publicKey).toContain('ssh-ed25519');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should generate an RSA key pair', async () => {
|
||||||
|
const keyPair = await generateSshKeyPair('rsa');
|
||||||
|
expect(keyPair.privateKey).toBeTruthy();
|
||||||
|
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||||
|
expect(keyPair.publicKey).toBeTruthy();
|
||||||
|
expect(keyPair.publicKey).toContain('ssh-rsa');
|
||||||
|
});
|
||||||
|
|
||||||
it('should check for git and ssh folders and create them if required', async () => {
|
it('should check for git and ssh folders and create them if required', async () => {
|
||||||
const userFolder = UserSettings.getUserN8nFolderPath();
|
const userFolder = UserSettings.getUserN8nFolderPath();
|
||||||
const sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER);
|
const sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER);
|
||||||
|
@ -242,7 +251,7 @@ describe('Source Control', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should class validate correct preferences', async () => {
|
it('should class validate correct preferences', async () => {
|
||||||
const validPreferences = {
|
const validPreferences: Partial<SourceControlPreferences> = {
|
||||||
branchName: 'main',
|
branchName: 'main',
|
||||||
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
|
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
|
||||||
branchReadOnly: false,
|
branchReadOnly: false,
|
||||||
|
|
Loading…
Reference in a new issue