Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Add SSH key generation #6006

Merged
merged 5 commits into from
Apr 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/License.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES);
}

isVersionControlEnabled() {
isVersionControlLicensed() {
return this.isFeatureEnabled(LICENSE_FEATURES.VERSION_CONTROL);
}

Expand Down
30 changes: 22 additions & 8 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -356,7 +358,7 @@ class Server extends AbstractServer {
saml: isSamlLicensed(),
advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(),
variables: isVariablesEnabled(),
versionControl: isVersionControlEnabled(),
versionControl: isVersionControlLicensed(),
});

if (isLdapEnabled()) {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -421,6 +424,7 @@ class Server extends AbstractServer {
postHog,
}),
new SamlController(samlService),
new VersionControlController(versionControlService),
];

if (isLdapEnabled()) {
Expand Down Expand Up @@ -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}`);
}
try {
await Container.get(SamlService).init();
} catch (error) {
LoggerProxy.warn(`SAML initialization failed: ${error.message}`);
}

// ----------------------------------------
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

This file was deleted.

1 change: 1 addition & 0 deletions packages/cli/src/environments/versionControl/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const VERSION_CONTROL_PREFERENCES_DB_KEY = 'features.versionControl';
Original file line number Diff line number Diff line change
@@ -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' });
}
};
4 changes: 4 additions & 0 deletions packages/cli/src/environments/versionControl/types/keyPair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface KeyPair {
privateKey: string;
publicKey: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IsString } from 'class-validator';

export class VersionControlPreferences {
@IsString()
privateKey: string;

@IsString()
publicKey: string;
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this function could check if a key already exists and throw an error in case it does - or at least in the future, check if there is a connected repo.

If a repo is connected, we want to prevent generating new keys at random WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really just a temporary helper function for now - once it is used in the context of the actual saving of settings, it will be adjusted to check. Right now there is just nothing to check yet and I wanted to keeps this PR small and just contain the SSH key related functions.

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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 3 additions & 1 deletion packages/cli/src/sso/saml/saml.service.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/test/integration/environments/VersionControl.test.ts
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
});
1 change: 1 addition & 0 deletions packages/cli/test/integration/shared/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type EndpointGroup =
| 'nodes'
| 'ldap'
| 'saml'
| 'versionControl'
| 'eventBus'
| 'license'
| 'variables';
Expand Down
Loading