mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat(core): Add SSH key generation (#6006)
* basic prefs and ssh key generation * review change * cleanup save * lint fix
This commit is contained in:
parent
953198e092
commit
71ed1f410c
|
@ -97,6 +97,7 @@
|
|||
"@types/replacestream": "^4.0.1",
|
||||
"@types/send": "^0.17.1",
|
||||
"@types/shelljs": "^0.8.11",
|
||||
"@types/sshpk": "^1.17.1",
|
||||
"@types/superagent": "4.1.13",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/syslog-client": "^1.1.2",
|
||||
|
@ -142,8 +143,8 @@
|
|||
"curlconverter": "^3.0.0",
|
||||
"dotenv": "^8.0.0",
|
||||
"express": "^4.18.2",
|
||||
"express-handlebars": "^7.0.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-handlebars": "^7.0.2",
|
||||
"express-openapi-validator": "^4.13.6",
|
||||
"express-prom-bundle": "^6.6.0",
|
||||
"fast-glob": "^3.2.5",
|
||||
|
@ -199,6 +200,7 @@
|
|||
"source-map-support": "^0.5.21",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sse-channel": "^4.0.0",
|
||||
"sshpk": "^1.17.0",
|
||||
"swagger-ui-express": "^4.3.0",
|
||||
"syslog-client": "^1.1.1",
|
||||
"typedi": "^0.10.0",
|
||||
|
|
|
@ -128,7 +128,7 @@ export class License {
|
|||
return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES);
|
||||
}
|
||||
|
||||
isVersionControlEnabled() {
|
||||
isVersionControlLicensed() {
|
||||
return this.isFeatureEnabled(LICENSE_FEATURES.VERSION_CONTROL);
|
||||
}
|
||||
|
||||
|
|
|
@ -160,7 +160,9 @@ import { variablesController } from './environments/variables.controller';
|
|||
import { LdapManager } from './Ldap/LdapManager.ee';
|
||||
import { getVariablesLimit, isVariablesEnabled } from '@/environments/enviromentHelpers';
|
||||
import { getCurrentAuthenticationMethod } from './sso/ssoHelpers';
|
||||
import { isVersionControlEnabled } from './environment/versionControl/versionControlHelper';
|
||||
import { isVersionControlLicensed } from '@/environments/versionControl/versionControlHelper';
|
||||
import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee';
|
||||
import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee';
|
||||
|
||||
const exec = promisify(callbackExec);
|
||||
|
||||
|
@ -356,7 +358,7 @@ class Server extends AbstractServer {
|
|||
saml: isSamlLicensed(),
|
||||
advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(),
|
||||
variables: isVariablesEnabled(),
|
||||
versionControl: isVersionControlEnabled(),
|
||||
versionControl: isVersionControlLicensed(),
|
||||
});
|
||||
|
||||
if (isLdapEnabled()) {
|
||||
|
@ -393,6 +395,7 @@ class Server extends AbstractServer {
|
|||
const mailer = Container.get(UserManagementMailer);
|
||||
const postHog = this.postHog;
|
||||
const samlService = Container.get(SamlService);
|
||||
const versionControlService = Container.get(VersionControlService);
|
||||
|
||||
const controllers: object[] = [
|
||||
new EventBusController(),
|
||||
|
@ -421,6 +424,7 @@ class Server extends AbstractServer {
|
|||
postHog,
|
||||
}),
|
||||
new SamlController(samlService),
|
||||
new VersionControlController(versionControlService),
|
||||
];
|
||||
|
||||
if (isLdapEnabled()) {
|
||||
|
@ -545,12 +549,10 @@ class Server extends AbstractServer {
|
|||
|
||||
// initialize SamlService if it is licensed, even if not enabled, to
|
||||
// set up the initial environment
|
||||
if (isSamlLicensed()) {
|
||||
try {
|
||||
await Container.get(SamlService).init();
|
||||
} catch (error) {
|
||||
LoggerProxy.error(`SAML initialization failed: ${error.message}`);
|
||||
}
|
||||
LoggerProxy.warn(`SAML initialization failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
@ -559,6 +561,18 @@ class Server extends AbstractServer {
|
|||
|
||||
this.app.use(`/${this.restEndpoint}/variables`, variablesController);
|
||||
|
||||
// ----------------------------------------
|
||||
// Version Control
|
||||
// ----------------------------------------
|
||||
|
||||
// initialize SamlService if it is licensed, even if not enabled, to
|
||||
// set up the initial environment
|
||||
try {
|
||||
await Container.get(VersionControlService).init();
|
||||
} catch (error) {
|
||||
LoggerProxy.warn(`Version Control initialization failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
// Returns parameter values which normally get loaded from an external API or
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import Container from 'typedi';
|
||||
import { License } from '../../License';
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
|
||||
export function isVersionControlEnabled() {
|
||||
const license = Container.get(License);
|
||||
return license.isVersionControlLicensed();
|
||||
}
|
||||
|
||||
export async function generateSshKeyPair() {
|
||||
const keyPair = generateKeyPairSync('ed25519', {
|
||||
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
|
||||
publicKeyEncoding: { format: 'pem', type: 'spki' },
|
||||
});
|
||||
|
||||
console.log(keyPair.privateKey);
|
||||
console.log(keyPair.publicKey);
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
import Container from 'typedi';
|
||||
import { License } from '../../License';
|
||||
|
||||
export function isVersionControlEnabled() {
|
||||
const license = Container.get(License);
|
||||
return license.isVersionControlEnabled();
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export const VERSION_CONTROL_PREFERENCES_DB_KEY = 'features.versionControl';
|
|
@ -0,0 +1,34 @@
|
|||
import type { RequestHandler } from 'express';
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
import {
|
||||
isVersionControlLicensed,
|
||||
isVersionControlLicensedAndEnabled,
|
||||
} from '../versionControlHelper';
|
||||
|
||||
export const versionControlLicensedOwnerMiddleware: RequestHandler = (
|
||||
req: AuthenticatedRequest,
|
||||
res,
|
||||
next,
|
||||
) => {
|
||||
if (isVersionControlLicensed() && req.user?.globalRole.name === 'owner') {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||
}
|
||||
};
|
||||
|
||||
export const versionControlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => {
|
||||
if (isVersionControlLicensedAndEnabled()) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||
}
|
||||
};
|
||||
|
||||
export const versionControlLicensedMiddleware: RequestHandler = (req, res, next) => {
|
||||
if (isVersionControlLicensed()) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||
}
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
export interface KeyPair {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class VersionControlPreferences {
|
||||
@IsString()
|
||||
privateKey: string;
|
||||
|
||||
@IsString()
|
||||
publicKey: string;
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { Get, RestController } from '../../decorators';
|
||||
import {
|
||||
versionControlLicensedMiddleware,
|
||||
versionControlLicensedOwnerMiddleware,
|
||||
} from './middleware/versionControlEnabledMiddleware';
|
||||
import { VersionControlService } from './versionControl.service.ee';
|
||||
|
||||
@RestController('/versionControl')
|
||||
export class VersionControlController {
|
||||
constructor(private versionControlService: VersionControlService) {}
|
||||
|
||||
@Get('/preferences', { middlewares: [versionControlLicensedMiddleware] })
|
||||
async getPreferences() {
|
||||
return this.versionControlService.versionControlPreferences;
|
||||
}
|
||||
|
||||
//TODO: temporary function to generate key and save new pair
|
||||
@Get('/generateKeyPair', { middlewares: [versionControlLicensedOwnerMiddleware] })
|
||||
async generateKeyPair() {
|
||||
return this.versionControlService.generateAndSaveKeyPair();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { Service } from 'typedi';
|
||||
import { generateSshKeyPair } from './versionControlHelper';
|
||||
import { VersionControlPreferences } from './types/versionControlPreferences';
|
||||
import { VERSION_CONTROL_PREFERENCES_DB_KEY } from './constants';
|
||||
import * as Db from '@/Db';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
@Service()
|
||||
export class VersionControlService {
|
||||
private _versionControlPreferences: VersionControlPreferences = new VersionControlPreferences();
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.loadFromDbAndApplyVersionControlPreferences();
|
||||
}
|
||||
|
||||
public get versionControlPreferences(): VersionControlPreferences {
|
||||
return {
|
||||
...this._versionControlPreferences,
|
||||
privateKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
async generateAndSaveKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
|
||||
const keyPair = generateSshKeyPair(keyType);
|
||||
if (keyPair.publicKey && keyPair.privateKey) {
|
||||
this.setPreferences({ ...keyPair });
|
||||
await this.saveSamlPreferencesToDb();
|
||||
}
|
||||
return keyPair;
|
||||
}
|
||||
|
||||
setPreferences(prefs: Partial<VersionControlPreferences>) {
|
||||
this._versionControlPreferences = {
|
||||
...this._versionControlPreferences,
|
||||
...prefs,
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromDbAndApplyVersionControlPreferences(): Promise<
|
||||
VersionControlPreferences | undefined
|
||||
> {
|
||||
const loadedPrefs = await Db.collections.Settings.findOne({
|
||||
where: { key: VERSION_CONTROL_PREFERENCES_DB_KEY },
|
||||
});
|
||||
if (loadedPrefs) {
|
||||
try {
|
||||
const prefs = jsonParse<VersionControlPreferences>(loadedPrefs.value);
|
||||
if (prefs) {
|
||||
this.setPreferences(prefs);
|
||||
return prefs;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async saveSamlPreferencesToDb(): Promise<VersionControlPreferences | undefined> {
|
||||
const settingsValue = JSON.stringify(this._versionControlPreferences);
|
||||
const result = await Db.collections.Settings.save({
|
||||
key: VERSION_CONTROL_PREFERENCES_DB_KEY,
|
||||
value: settingsValue,
|
||||
loadOnStartup: true,
|
||||
});
|
||||
if (result)
|
||||
try {
|
||||
return jsonParse<VersionControlPreferences>(result.value);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import Container from 'typedi';
|
||||
import { License } from '../../License';
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
import sshpk from 'sshpk';
|
||||
import type { KeyPair } from './types/keyPair';
|
||||
|
||||
export function isVersionControlLicensed() {
|
||||
const license = Container.get(License);
|
||||
return license.isVersionControlLicensed();
|
||||
}
|
||||
|
||||
export function isVersionControlEnabled() {
|
||||
// TODO: VERSION CONTROL check if enabled
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isVersionControlLicensedAndEnabled() {
|
||||
return isVersionControlLicensed() && isVersionControlEnabled();
|
||||
}
|
||||
|
||||
export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
|
||||
const keyPair: KeyPair = {
|
||||
publicKey: '',
|
||||
privateKey: '',
|
||||
};
|
||||
let generatedKeyPair: KeyPair;
|
||||
switch (keyType) {
|
||||
case 'ed25519':
|
||||
generatedKeyPair = generateKeyPairSync('ed25519', {
|
||||
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
|
||||
publicKeyEncoding: { format: 'pem', type: 'spki' },
|
||||
});
|
||||
break;
|
||||
case 'rsa':
|
||||
generatedKeyPair = generateKeyPairSync('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem');
|
||||
keyPair.publicKey = keyPublic.toString('ssh');
|
||||
const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem');
|
||||
keyPair.privateKey = keyPrivate.toString('ssh-private');
|
||||
return keyPair;
|
||||
}
|
|
@ -302,7 +302,9 @@ export class SamlService {
|
|||
);
|
||||
} catch (error) {
|
||||
// throw error;
|
||||
throw new AuthError('SAML Authentication failed. Could not parse SAML response.');
|
||||
throw new AuthError(
|
||||
`SAML Authentication failed. Could not parse SAML response. ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult(
|
||||
parsedSamlResponse,
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { Container } from 'typedi';
|
||||
import type { SuperAgentTest } from 'supertest';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { License } from '@/License';
|
||||
import * as testDb from '../shared/testDb';
|
||||
import * as utils from '../shared/utils';
|
||||
import { VersionControlService } from '../../../src/environments/versionControl/versionControl.service.ee';
|
||||
|
||||
let owner: User;
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
|
||||
beforeAll(async () => {
|
||||
Container.get(License).isVersionControlLicensed = () => true;
|
||||
const app = await utils.initTestServer({ endpointGroups: ['versionControl'] });
|
||||
owner = await testDb.createOwner();
|
||||
authOwnerAgent = utils.createAuthAgent(app)(owner);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('GET /versionControl/preferences', () => {
|
||||
test('should return Version Control preferences', async () => {
|
||||
await Container.get(VersionControlService).generateAndSaveKeyPair();
|
||||
await authOwnerAgent
|
||||
.get('/versionControl/preferences')
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
return (
|
||||
'privateKey' in res.body &&
|
||||
'publicKey' in res.body &&
|
||||
res.body.publicKey.includes('ssh-ed25519') &&
|
||||
res.body.privateKey.includes('BEGIN OPENSSH PRIVATE KEY')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -23,6 +23,7 @@ type EndpointGroup =
|
|||
| 'nodes'
|
||||
| 'ldap'
|
||||
| 'saml'
|
||||
| 'versionControl'
|
||||
| 'eventBus'
|
||||
| 'license'
|
||||
| 'variables';
|
||||
|
|
|
@ -82,6 +82,8 @@ import { SamlService } from '@/sso/saml/saml.service.ee';
|
|||
import { SamlController } from '@/sso/saml/routes/saml.controller.ee';
|
||||
import { EventBusController } from '@/eventbus/eventBus.controller';
|
||||
import { License } from '@/License';
|
||||
import { VersionControlService } from '@/environments/versionControl/versionControl.service.ee';
|
||||
import { VersionControlController } from '@/environments/versionControl/versionControl.controller.ee';
|
||||
|
||||
export const mockInstance = <T>(
|
||||
ctor: new (...args: any[]) => T,
|
||||
|
@ -202,6 +204,14 @@ export async function initTestServer({
|
|||
const samlService = Container.get(SamlService);
|
||||
registerController(testServer.app, config, new SamlController(samlService));
|
||||
break;
|
||||
case 'versionControl':
|
||||
const versionControlService = Container.get(VersionControlService);
|
||||
registerController(
|
||||
testServer.app,
|
||||
config,
|
||||
new VersionControlController(versionControlService),
|
||||
);
|
||||
break;
|
||||
case 'nodes':
|
||||
registerController(
|
||||
testServer.app,
|
||||
|
|
11
packages/cli/test/unit/VersionControl.test.ts
Normal file
11
packages/cli/test/unit/VersionControl.test.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { generateSshKeyPair } from '../../src/environments/versionControl/versionControlHelper';
|
||||
|
||||
describe('Version Control', () => {
|
||||
it('should generate an SSH key pair', () => {
|
||||
const keyPair = generateSshKeyPair();
|
||||
expect(keyPair.privateKey).toBeTruthy();
|
||||
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||
expect(keyPair.publicKey).toBeTruthy();
|
||||
expect(keyPair.publicKey).toContain('ssh-ed25519');
|
||||
});
|
||||
});
|
|
@ -406,6 +406,9 @@ importers:
|
|||
sse-channel:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
sshpk:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
swagger-ui-express:
|
||||
specifier: ^4.3.0
|
||||
version: 4.5.0(express@4.18.2)
|
||||
|
@ -536,6 +539,9 @@ importers:
|
|||
'@types/shelljs':
|
||||
specifier: ^0.8.11
|
||||
version: 0.8.11
|
||||
'@types/sshpk':
|
||||
specifier: ^1.17.1
|
||||
version: 1.17.1
|
||||
'@types/superagent':
|
||||
specifier: 4.1.13
|
||||
version: 4.1.13
|
||||
|
@ -5959,7 +5965,6 @@ packages:
|
|||
resolution: {integrity: sha512-5TMxIpYbIA9c1J0hYQjQDX3wr+rTgQEAXaW2BI8ECM8FO53wSW4HFZplTalrKSHuZUc76NtXcePRhwuOHqGD5g==}
|
||||
dependencies:
|
||||
'@types/node': 16.18.12
|
||||
dev: false
|
||||
|
||||
/@types/aws4@1.11.2:
|
||||
resolution: {integrity: sha512-x0f96eBPrCCJzJxdPbUvDFRva4yPpINJzTuXXpmS2j9qLUpF2nyGzvXPlRziuGbCsPukwY4JfuO+8xwsoZLzGw==}
|
||||
|
@ -6882,6 +6887,13 @@ packages:
|
|||
'@types/node': 16.18.12
|
||||
dev: true
|
||||
|
||||
/@types/sshpk@1.17.1:
|
||||
resolution: {integrity: sha512-bOJek/W++DvWRNAeHmpvgX8Q1ypAq4nmeVi3nJ+pjDcMB214S8kSGkxRUw/Uz+zau4VwxcfNp0xUq4s/3DLjLw==}
|
||||
dependencies:
|
||||
'@types/asn1': 0.2.0
|
||||
'@types/node': 16.18.12
|
||||
dev: true
|
||||
|
||||
/@types/stack-utils@2.0.1:
|
||||
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
|
||||
dev: true
|
||||
|
|
Loading…
Reference in a new issue