feat(core): Add license support to n8n (#4566)
* add sdk * add license manager * type fix * add basic func * store to db * update default * activate license * add sharing flag * fix setup * clear license * update conosle log to info * refactor * use npm dependency * update error logs * add simple test * add license tests * update tests * update pnpm package * fix error handling types * Update packages/cli/src/config/schema.ts Co-authored-by: Cornelius Suermann <cornelius@n8n.io> * make feature enum * add warning * update sdk * Update packages/cli/src/config/schema.ts Co-authored-by: Cornelius Suermann <cornelius@n8n.io> Co-authored-by: Cornelius Suermann <cornelius@n8n.io>
This commit is contained in:
121
packages/cli/src/License.ts
Normal file
121
packages/cli/src/License.ts
Normal file
@@ -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<TLicenseContainerStr> {
|
||||
const databaseSettings = await Db.collections.Settings.findOne({
|
||||
where: {
|
||||
key: SETTINGS_LICENSE_CERT_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
return databaseSettings?.value ?? '';
|
||||
}
|
||||
|
||||
async function saveCertStr(value: TLicenseContainerStr): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 = [
|
||||
|
||||
@@ -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<User> {
|
||||
@@ -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 {
|
||||
|
||||
42
packages/cli/src/commands/license/clear.ts
Normal file
42
packages/cli/src/commands/license/clear.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user