diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d97488112..6a5e097144 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -103,12 +103,13 @@ "tsconfig-paths": "^3.14.1" }, "dependencies": { + "@n8n_io/license-sdk": "^1.3.4", "@oclif/command": "^1.8.16", "@oclif/core": "^1.16.4", "@oclif/errors": "^1.3.6", "@rudderstack/rudder-sdk-node": "1.0.6", - "@sentry/node": "^7.17.3", "@sentry/integrations": "^7.17.3", + "@sentry/node": "^7.17.3", "axios": "^0.21.1", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts new file mode 100644 index 0000000000..1e7e97cb36 --- /dev/null +++ b/packages/cli/src/License.ts @@ -0,0 +1,121 @@ +import { LicenseManager, TLicenseContainerStr } from '@n8n_io/license-sdk'; +import { ILogger } from 'n8n-workflow'; +import { getLogger } from './Logger'; +import config from '@/config'; +import * as Db from '@/Db'; +import { LICENSE_FEATURES, SETTINGS_LICENSE_CERT_KEY } from './constants'; + +async function loadCertStr(): Promise { + const databaseSettings = await Db.collections.Settings.findOne({ + where: { + key: SETTINGS_LICENSE_CERT_KEY, + }, + }); + + return databaseSettings?.value ?? ''; +} + +async function saveCertStr(value: TLicenseContainerStr): Promise { + await Db.collections.Settings.upsert( + { + key: SETTINGS_LICENSE_CERT_KEY, + value, + loadOnStartup: false, + }, + ['key'], + ); +} + +export class License { + private logger: ILogger; + + private manager: LicenseManager | undefined; + + constructor() { + this.logger = getLogger(); + } + + async init(instanceId: string, version: string) { + if (this.manager) { + return; + } + + const server = config.getEnv('license.serverUrl'); + const autoRenewEnabled = config.getEnv('license.autoRenewEnabled'); + const autoRenewOffset = config.getEnv('license.autoRenewOffset'); + + try { + this.manager = new LicenseManager({ + server, + tenantId: 1, + productIdentifier: `n8n-${version}`, + autoRenewEnabled, + autoRenewOffset, + logger: this.logger, + loadCertStr, + saveCertStr, + deviceFingerprint: () => instanceId, + }); + + await this.manager.initialize(); + } catch (e: unknown) { + if (e instanceof Error) { + this.logger.error('Could not initialize license manager sdk', e); + } + } + } + + async activate(activationKey: string): Promise { + if (!this.manager) { + return; + } + + if (this.manager.isValid()) { + return; + } + + try { + await this.manager.activate(activationKey); + } catch (e) { + if (e instanceof Error) { + this.logger.error('Could not activate license', e); + } + } + } + + async renew() { + if (!this.manager) { + return; + } + + try { + await this.manager.renew(); + } catch (e) { + if (e instanceof Error) { + this.logger.error('Could not renew license', e); + } + } + } + + isFeatureEnabled(feature: string): boolean { + if (!this.manager) { + return false; + } + + return this.manager.hasFeatureEnabled(feature); + } + + isSharingEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.SHARING); + } +} + +let licenseInstance: License | undefined; + +export function getLicense(): License { + if (licenseInstance === undefined) { + licenseInstance = new License(); + } + + return licenseInstance; +} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 810d028b25..82fbb00790 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -160,6 +160,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData' import { ResponseError } from '@/ResponseHelper'; import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { setupErrorMiddleware } from '@/ErrorReporting'; +import { getLicense } from '@/License'; require('body-parser-xml')(bodyParser); @@ -384,6 +385,16 @@ class App { return this.frontendSettings; } + async initLicense(): Promise { + const license = getLicense(); + await license.init(this.frontendSettings.instanceId, this.frontendSettings.versionCli); + + const activationKey = config.getEnv('license.activationKey'); + if (activationKey) { + await license.activate(activationKey); + } + } + async config(): Promise { const enableMetrics = config.getEnv('endpoints.metrics.enable'); let register: Registry; @@ -406,6 +417,8 @@ class App { await this.externalHooks.run('frontend.settings', [this.frontendSettings]); + await this.initLicense(); + const excludeEndpoints = config.getEnv('security.excludeEndpoints'); const ignoredEndpoints = [ diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 2cefec4dae..213a5839eb 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -13,6 +13,7 @@ import { Role } from '@db/entities/Role'; import { AuthenticatedRequest } from '@/requests'; import config from '@/config'; import { getWebhookBaseUrl } from '../WebhookHelpers'; +import { getLicense } from '@/License'; import { WhereClause } from '@/Interfaces'; export async function getWorkflowOwner(workflowId: string | number): Promise { @@ -41,7 +42,11 @@ export function isUserManagementEnabled(): boolean { } export function isSharingEnabled(): boolean { - return isUserManagementEnabled() && config.getEnv('enterprise.features.sharing'); + const license = getLicense(); + return ( + isUserManagementEnabled() && + (config.getEnv('enterprise.features.sharing') || license.isSharingEnabled()) + ); } export function isUserManagementDisabled(): boolean { diff --git a/packages/cli/src/commands/license/clear.ts b/packages/cli/src/commands/license/clear.ts new file mode 100644 index 0000000000..7c94924bac --- /dev/null +++ b/packages/cli/src/commands/license/clear.ts @@ -0,0 +1,42 @@ +import { Command } from '@oclif/command'; + +import { LoggerProxy } from 'n8n-workflow'; + +import * as Db from '@/Db'; + +import { getLogger } from '@/Logger'; +import { SETTINGS_LICENSE_CERT_KEY } from '@/constants'; + +export class ClearLicenseCommand extends Command { + static description = 'Clear license'; + + static examples = [`$ n8n clear:license`]; + + async run() { + const logger = getLogger(); + LoggerProxy.init(logger); + + try { + await Db.init(); + + console.info('Clearing license from database.'); + await Db.collections.Settings.delete({ + key: SETTINGS_LICENSE_CERT_KEY, + }); + console.info('Done. Restart n8n to take effect.'); + } catch (e: unknown) { + console.error('Error updating database. See log messages for details.'); + logger.error('\nGOT ERROR'); + logger.info('===================================='); + if (e instanceof Error) { + logger.error(e.message); + if (e.stack) { + logger.error(e.stack); + } + } + this.exit(1); + } + + this.exit(); + } +} diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index fbca7d0983..5b779e3511 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -987,4 +987,31 @@ export const schema = { env: 'N8N_ONBOARDING_CALL_PROMPTS_ENABLED', }, }, + + license: { + serverUrl: { + format: String, + default: 'https://license.n8n.io/v1', + env: 'N8N_LICENSE_SERVER_URL', + doc: 'License server url to retrieve license.', + }, + autoRenewEnabled: { + format: Boolean, + default: true, + env: 'N8N_LICENSE_AUTO_RENEW_ENABLED', + doc: 'Whether autorenew for licenses is enabled.', + }, + autoRenewOffset: { + format: Number, + default: 60 * 60 * 72, // 72 hours + env: 'N8N_LICENSE_AUTO_RENEW_OFFSET', + doc: 'How many seconds before expiry a license should get automatically renewed. ', + }, + activationKey: { + format: String, + default: '', + env: 'N8N_LICENSE_ACTIVATION_KEY', + doc: 'Activation key to initialize license', + }, + }, }; diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 61477997d3..9911a43c7a 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -41,3 +41,9 @@ export const UNKNOWN_FAILURE_REASON = 'Unknown failure reason'; export const WORKFLOW_REACTIVATE_INITIAL_TIMEOUT = 1000; export const WORKFLOW_REACTIVATE_MAX_TIMEOUT = 180000; + +export const SETTINGS_LICENSE_CERT_KEY = 'license.cert'; + +export enum LICENSE_FEATURES { + SHARING = 'feat:sharing', +} diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts new file mode 100644 index 0000000000..3a63c673e3 --- /dev/null +++ b/packages/cli/test/unit/License.test.ts @@ -0,0 +1,77 @@ +import { LicenseManager } from '@n8n_io/license-sdk'; +import config from '@/config'; +import { License } from '@/License'; + +jest.mock('@n8n_io/license-sdk'); + +const MOCK_SERVER_URL = 'https://server.com/v1'; +const MOCK_RENEW_OFFSET = 259200; +const MOCK_INSTANCE_ID = 'instance-id'; +const MOCK_N8N_VERSION = '0.27.0'; +const MOCK_ACTIVATION_KEY = 'activation-key'; +const MOCK_FEATURE_FLAG = 'feat:mock'; + +describe('License', () => { + beforeAll(() => { + config.set('license.serverUrl', MOCK_SERVER_URL); + config.set('license.autoRenewEnabled', true); + config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET); + }); + + let license; + + beforeEach(async () => { + license = new License(); + await license.init(MOCK_INSTANCE_ID, MOCK_N8N_VERSION); + }); + + test('initializes license manager', async () => { + expect(LicenseManager).toHaveBeenCalledWith({ + autoRenewEnabled: true, + autoRenewOffset: MOCK_RENEW_OFFSET, + deviceFingerprint: expect.any(Function), + productIdentifier: `n8n-${MOCK_N8N_VERSION}`, + logger: expect.anything(), + loadCertStr: expect.any(Function), + saveCertStr: expect.any(Function), + server: MOCK_SERVER_URL, + tenantId: 1, + }); + }); + + test('activates license if current license is not valid', async () => { + LicenseManager.prototype.isValid.mockReturnValue(false); + + await license.activate(MOCK_ACTIVATION_KEY); + + expect(LicenseManager.prototype.isValid).toHaveBeenCalled(); + expect(LicenseManager.prototype.activate).toHaveBeenCalledWith(MOCK_ACTIVATION_KEY); + }); + + test('does not activate license if current license is valid', async () => { + LicenseManager.prototype.isValid.mockReturnValue(true); + + await license.activate(MOCK_ACTIVATION_KEY); + + expect(LicenseManager.prototype.isValid).toHaveBeenCalled(); + expect(LicenseManager.prototype.activate).not.toHaveBeenCalledWith(); + }); + + test('renews license', async () => { + await license.renew(); + + expect(LicenseManager.prototype.renew).toHaveBeenCalled(); + }); + + test('check if feature is enabled', async () => { + await license.isFeatureEnabled(MOCK_FEATURE_FLAG); + + expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); + }); + + test('check if sharing feature is enabled', async () => { + await license.isFeatureEnabled(MOCK_FEATURE_FLAG); + + expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb9406899c..82a4ac5673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,7 @@ importers: packages/cli: specifiers: '@apidevtools/swagger-cli': 4.0.0 + '@n8n_io/license-sdk': ^1.3.4 '@oclif/command': ^1.8.16 '@oclif/core': ^1.16.4 '@oclif/dev-cli': ^1.22.2 @@ -203,6 +204,7 @@ importers: winston: ^3.3.3 yamljs: ^0.3.0 dependencies: + '@n8n_io/license-sdk': 1.3.4 '@oclif/command': 1.8.18_@oclif+config@1.18.5 '@oclif/core': 1.16.6 '@oclif/errors': 1.3.6 @@ -3306,6 +3308,18 @@ packages: resolution: {integrity: sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==} dev: true + /@n8n_io/license-sdk/1.3.4: + resolution: {integrity: sha512-t/J228ftRwTbuCYTqMtjRjfqeNE8Aq0DErYFZS5N9EbX5brNIzWbgGio+25utoI9t31xRPwpviDF+e0kPz8Fvg==} + engines: {node: '>=14.0.0', npm: '>=7.10.0'} + dependencies: + axios: 1.1.3 + crypto-js: 4.1.1 + node-machine-id: 1.1.12 + node-rsa: 1.1.1 + transitivePeerDependencies: + - debug + dev: false + /@n8n_io/riot-tmpl/1.0.1: resolution: {integrity: sha512-+ig7/rafN3LGthGEi8fs1N5XxPndmRq5YAX92DWOar9mrMDrYyIjK5XAQaTnTMDQgmKKllrAl+bVRmQXKcLFuw==} dependencies: @@ -7633,6 +7647,16 @@ packages: - debug dev: false + /axios/1.1.3: + resolution: {integrity: sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==} + dependencies: + follow-redirects: 1.15.2_debug@3.2.7 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /babel-core/7.0.0-bridge.0_@babel+core@7.19.3: resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} peerDependencies: @@ -16077,6 +16101,10 @@ packages: vm-browserify: 1.1.2 dev: true + /node-machine-id/1.1.12: + resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} + dev: false + /node-notifier/10.0.1: resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==} dependencies: @@ -16092,6 +16120,12 @@ packages: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} dev: true + /node-rsa/1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + dependencies: + asn1: 0.2.6 + dev: false + /node-ssh/12.0.5: resolution: {integrity: sha512-uN2GTGdBRUUKkZmcNBr9OM+xKL6zq74emnkSyb1TshBdVWegj3boue6QallQeqZzo7YGVheP5gAovUL+8hZSig==} engines: {node: '>= 10'}