mirror of
https://github.com/strapi/strapi.git
synced 2025-11-02 02:44:55 +00:00
Add online license check
This commit is contained in:
parent
5a2b379fa0
commit
b37a8be684
@ -8,7 +8,7 @@ const mergeRoutes = (a, b, key) => {
|
|||||||
return _.isArray(a) && _.isArray(b) && key === 'routes' ? a.concat(b) : undefined;
|
return _.isArray(a) && _.isArray(b) && key === 'routes' ? a.concat(b) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.STRAPI_DISABLE_EE !== 'true' && strapi.EE) {
|
if (strapi.EE) {
|
||||||
const eeAdmin = require('./ee/strapi-server');
|
const eeAdmin = require('./ee/strapi-server');
|
||||||
|
|
||||||
module.exports = _.mergeWith({}, admin, eeAdmin, mergeRoutes);
|
module.exports = _.mergeWith({}, admin, eeAdmin, mergeRoutes);
|
||||||
|
|||||||
@ -1,132 +1,211 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const { join } = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const _ = require('lodash');
|
const fetch = require('node-fetch');
|
||||||
|
const { pick } = require('lodash/fp');
|
||||||
|
|
||||||
const publicKey = fs.readFileSync(path.join(__dirname, 'resources/key.pub'));
|
const { coreStoreModel } = require('../lib/services/core-store');
|
||||||
|
|
||||||
const noop = () => {};
|
const publicKey = fs.readFileSync(join(__dirname, 'resources/key.pub'));
|
||||||
|
|
||||||
const noLog = {
|
const ee = {
|
||||||
warn: noop,
|
enabled: false,
|
||||||
info: noop,
|
licenseInfo: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const disable = (message = 'Invalid license. Starting in CE.') => {
|
||||||
|
ee.logger?.warn(message);
|
||||||
|
// Only keep the license key for potential re-enabling during a later check
|
||||||
|
ee.licenseInfo = pick('licenseKey', ee.licenseInfo);
|
||||||
|
ee.enabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readLicense = (directory) => {
|
||||||
|
try {
|
||||||
|
const path = join(directory, 'license.txt');
|
||||||
|
return fs.readFileSync(path).toString();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
// Permission denied, directory found instead of file, etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLicense = async (key, fallback) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://license.strapi.io/api/licenses/${key}`);
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
disable();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof fetch.FetchError) {
|
||||||
|
if (fallback) {
|
||||||
|
ee.logger(
|
||||||
|
'Could not proceed to the online verification of your license. We will try to use your locally stored one as a potential fallback.'
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(
|
||||||
|
'Could not proceed to the online verification of your license, sorry for the inconvenience. Starting in CE.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyLicense = (license) => {
|
||||||
|
const [signature, base64Content] = Buffer.from(license, 'base64').toString().split('\n');
|
||||||
|
const stringifiedContent = Buffer.from(base64Content, 'base64').toString();
|
||||||
|
|
||||||
|
const verifier = crypto.createVerify('RSA-SHA256');
|
||||||
|
verifier.update(stringifiedContent);
|
||||||
|
verifier.end();
|
||||||
|
|
||||||
|
const verified = verifier.verify(publicKey, signature, 'base64');
|
||||||
|
|
||||||
|
return { verified, licenseInfo: verified ? JSON.parse(stringifiedContent) : null };
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
const init = (licenseDir, logger) => {
|
||||||
|
// Can only be executed once, to prevent any abuse of the optimistic behavior
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
ee.logger = logger;
|
||||||
|
|
||||||
|
if (process.env.STRAPI_DISABLE_EE?.toLowerCase() === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = process.env.STRAPI_LICENSE || readLicense(licenseDir);
|
||||||
|
|
||||||
|
if (license) {
|
||||||
|
const { verified, licenseInfo } = verifyLicense(license);
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
ee.enabled = true; // Optimistically enable EE during initialization
|
||||||
|
ee.licenseInfo = licenseInfo;
|
||||||
|
} else {
|
||||||
|
return disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const oneMinute = 1000 * 60;
|
||||||
|
|
||||||
|
const onlineUpdate = async (db) => {
|
||||||
|
const transaction = await db.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Use the core store interface instead, it does not support transactions and "FOR UPDATE" at the moment
|
||||||
|
const eeInfo = await db
|
||||||
|
.queryBuilder(coreStoreModel.uid)
|
||||||
|
.where({ key: 'ee_information' })
|
||||||
|
.select('value')
|
||||||
|
.first()
|
||||||
|
.transacting(transaction)
|
||||||
|
.forUpdate()
|
||||||
|
.execute()
|
||||||
|
.then((result) => (result ? JSON.parse(result.value) : result));
|
||||||
|
|
||||||
|
const useStoredLicense = eeInfo?.lastOnlineCheck > Date.now() - oneMinute;
|
||||||
|
const license = useStoredLicense
|
||||||
|
? eeInfo.license
|
||||||
|
: await fetchLicense(ee.licenseInfo.licenseKey, eeInfo?.license);
|
||||||
|
|
||||||
|
if (license) {
|
||||||
|
const { verified, licenseInfo } = verifyLicense(license);
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
ee.licenseInfo = licenseInfo;
|
||||||
|
} else {
|
||||||
|
disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useStoredLicense) {
|
||||||
|
const value = { license, lastOnlineCheck: Date.now() };
|
||||||
|
const query = db.queryBuilder(coreStoreModel.uid).transacting(transaction);
|
||||||
|
|
||||||
|
if (!eeInfo) {
|
||||||
|
query.insert({ key: 'ee_information', value: JSON.stringify(value), type: typeof value });
|
||||||
|
} else {
|
||||||
|
query.update({ value: JSON.stringify(value) }).where({ key: 'ee_information' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await query.execute();
|
||||||
|
} else if (!license) {
|
||||||
|
disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
// TODO: The database can be locked at the time of writing, could just a SQLite issue only
|
||||||
|
await transaction.rollback();
|
||||||
|
return disable(error.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const internals = {};
|
|
||||||
const defaultFeatures = {
|
const defaultFeatures = {
|
||||||
bronze: [],
|
bronze: [],
|
||||||
silver: [],
|
silver: [],
|
||||||
gold: ['sso'],
|
gold: ['sso'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const EEService = ({ dir, logger = noLog }) => {
|
const validateInfo = () => {
|
||||||
if (_.has(internals, 'isEE')) return internals.isEE;
|
if (ee.licenseInfo.expireAt) {
|
||||||
|
return;
|
||||||
const warnAndReturn = (msg = 'Invalid license. Starting in CE.') => {
|
|
||||||
logger.warn(msg);
|
|
||||||
internals.isEE = false;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.env.STRAPI_DISABLE_EE === 'true') {
|
|
||||||
internals.isEE = false;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const licensePath = path.join(dir, 'license.txt');
|
const expirationTime = new Date(ee.licenseInfo.expireAt).getTime();
|
||||||
|
|
||||||
let license;
|
if (expirationTime < new Date().getTime()) {
|
||||||
if (_.has(process.env, 'STRAPI_LICENSE')) {
|
return disable('License expired. Starting in CE.');
|
||||||
license = process.env.STRAPI_LICENSE;
|
|
||||||
} else if (fs.existsSync(licensePath)) {
|
|
||||||
license = fs.readFileSync(licensePath).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_.isNil(license)) {
|
ee.enabled = true;
|
||||||
internals.isEE = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: optimistically return true if license key is valid
|
if (!ee.licenseInfo.features) {
|
||||||
|
ee.licenseInfo.features = defaultFeatures[ee.licenseInfo.type];
|
||||||
try {
|
|
||||||
const plainLicense = Buffer.from(license, 'base64').toString();
|
|
||||||
const [signatureb64, contentb64] = plainLicense.split('\n');
|
|
||||||
|
|
||||||
const signature = Buffer.from(signatureb64, 'base64');
|
|
||||||
const content = Buffer.from(contentb64, 'base64').toString();
|
|
||||||
|
|
||||||
const verifier = crypto.createVerify('RSA-SHA256');
|
|
||||||
verifier.update(content);
|
|
||||||
verifier.end();
|
|
||||||
|
|
||||||
const isValid = verifier.verify(publicKey, signature);
|
|
||||||
if (!isValid) return warnAndReturn();
|
|
||||||
|
|
||||||
internals.licenseInfo = JSON.parse(content);
|
|
||||||
internals.licenseInfo.features =
|
|
||||||
internals.licenseInfo.features || defaultFeatures[internals.licenseInfo.type];
|
|
||||||
|
|
||||||
const expirationTime = new Date(internals.licenseInfo.expireAt).getTime();
|
|
||||||
if (expirationTime < new Date().getTime()) {
|
|
||||||
return warnAndReturn('License expired. Starting in CE');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return warnAndReturn();
|
|
||||||
}
|
|
||||||
|
|
||||||
internals.isEE = true;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
EEService.checkLicense = async () => {
|
|
||||||
// TODO: online / DB check of the license info
|
|
||||||
// TODO: refresh info if the DB info is outdated
|
|
||||||
// TODO: register cron
|
|
||||||
// internals.licenseInfo = await db.getLicense();
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(EEService, 'licenseInfo', {
|
|
||||||
get() {
|
|
||||||
mustHaveKey('licenseInfo');
|
|
||||||
return internals.licenseInfo;
|
|
||||||
},
|
|
||||||
configurable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(EEService, 'isEE', {
|
|
||||||
get() {
|
|
||||||
mustHaveKey('isEE');
|
|
||||||
return internals.isEE;
|
|
||||||
},
|
|
||||||
configurable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(EEService, 'features', {
|
|
||||||
get() {
|
|
||||||
return {
|
|
||||||
isEnabled(feature) {
|
|
||||||
return internals.licenseInfo.features.includes(feature);
|
|
||||||
},
|
|
||||||
getEnabled() {
|
|
||||||
return internals.licenseInfo.features;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
configurable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mustHaveKey = (key) => {
|
|
||||||
if (!_.has(internals, key)) {
|
|
||||||
const err = new Error('Tampering with license');
|
|
||||||
// err.stack = null;
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = EEService;
|
const shouldStayOffline = process.env.STRAPI_DISABLE_LICENSE_PING?.toLowerCase() === 'true';
|
||||||
|
|
||||||
|
const checkLicense = async (db) => {
|
||||||
|
if (!shouldStayOffline) {
|
||||||
|
await onlineUpdate(db);
|
||||||
|
// TODO: Register cron, try to spread it out across projects to avoid regular request spikes
|
||||||
|
} else if (!ee.licenseInfo.expireAt) {
|
||||||
|
return disable('Your license does not have offline support. Starting in CE.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ee.enabled) {
|
||||||
|
validateInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
disable,
|
||||||
|
features: {
|
||||||
|
isEnabled: (feature) => (ee.enabled && ee.licenseInfo.features?.includes(feature)) || false,
|
||||||
|
getEnabled: () => (ee.enabled && Object.freeze(ee.licenseInfo.features)) || [],
|
||||||
|
},
|
||||||
|
checkLicense,
|
||||||
|
get isEE() {
|
||||||
|
return ee.enabled;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@ -126,6 +126,10 @@ class Strapi {
|
|||||||
return this.container.get('config');
|
return this.container.get('config');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get EE() {
|
||||||
|
return ee.isEE;
|
||||||
|
}
|
||||||
|
|
||||||
get services() {
|
get services() {
|
||||||
return this.container.get('services').getAll();
|
return this.container.get('services').getAll();
|
||||||
}
|
}
|
||||||
@ -365,7 +369,7 @@ class Strapi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async register() {
|
async register() {
|
||||||
await this.loadEE();
|
ee.init(this.dirs.app.root, this.log);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadApp(),
|
this.loadApp(),
|
||||||
@ -445,6 +449,10 @@ class Strapi {
|
|||||||
|
|
||||||
await this.db.schema.sync();
|
await this.db.schema.sync();
|
||||||
|
|
||||||
|
if (this.EE) {
|
||||||
|
await ee.checkLicense(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
await this.hook('strapi::content-types.afterSync').call({
|
await this.hook('strapi::content-types.afterSync').call({
|
||||||
oldContentTypes,
|
oldContentTypes,
|
||||||
contentTypes: strapi.contentTypes,
|
contentTypes: strapi.contentTypes,
|
||||||
@ -457,8 +465,6 @@ class Strapi {
|
|||||||
value: strapi.contentTypes,
|
value: strapi.contentTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ee.checkLicense();
|
|
||||||
|
|
||||||
await this.startWebhooks();
|
await this.startWebhooks();
|
||||||
|
|
||||||
await this.server.initMiddlewares();
|
await this.server.initMiddlewares();
|
||||||
@ -473,16 +479,6 @@ class Strapi {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadEE() {
|
|
||||||
Object.defineProperty(this, 'EE', {
|
|
||||||
get() {
|
|
||||||
return ee({ dir: this.dirs.app.root, logger: this.log });
|
|
||||||
},
|
|
||||||
configurable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
await this.register();
|
await this.register();
|
||||||
await this.bootstrap();
|
await this.bootstrap();
|
||||||
|
|||||||
@ -30,7 +30,7 @@ module.exports = async ({ buildDestDir, forceBuild = true, optimization, srcDir
|
|||||||
// Always remove the .cache and build folders
|
// Always remove the .cache and build folders
|
||||||
await strapiAdmin.clean({ appDir: srcDir, buildDestDir });
|
await strapiAdmin.clean({ appDir: srcDir, buildDestDir });
|
||||||
|
|
||||||
ee({ dir: srcDir });
|
ee.init(srcDir);
|
||||||
|
|
||||||
return strapiAdmin
|
return strapiAdmin
|
||||||
.build({
|
.build({
|
||||||
|
|||||||
@ -5,12 +5,8 @@
|
|||||||
* You can learn more at https://docs.strapi.io/developer-docs/latest/getting-started/usage-information.html
|
* You can learn more at https://docs.strapi.io/developer-docs/latest/getting-started/usage-information.html
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { scheduleJob } = require('node-schedule');
|
const { scheduleJob } = require('node-schedule');
|
||||||
|
|
||||||
const ee = require('../../utils/ee');
|
|
||||||
const wrapWithRateLimit = require('./rate-limiter');
|
const wrapWithRateLimit = require('./rate-limiter');
|
||||||
const createSender = require('./sender');
|
const createSender = require('./sender');
|
||||||
const createMiddleware = require('./middleware');
|
const createMiddleware = require('./middleware');
|
||||||
@ -46,42 +42,14 @@ const createTelemetryInstance = (strapi) => {
|
|||||||
strapi.server.use(createMiddleware({ sendEvent }));
|
strapi.server.use(createMiddleware({ sendEvent }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bootstrap() {
|
|
||||||
// TODO: remove
|
|
||||||
if (strapi.EE === true && ee.isEE === true) {
|
|
||||||
const pingDisabled =
|
|
||||||
isTruthy(process.env.STRAPI_LICENSE_PING_DISABLED) && ee.licenseInfo.type === 'gold';
|
|
||||||
|
|
||||||
const sendLicenseCheck = () => {
|
bootstrap() {},
|
||||||
return sendEvent(
|
|
||||||
'didCheckLicense',
|
|
||||||
{
|
|
||||||
groupProperties: {
|
|
||||||
licenseInfo: {
|
|
||||||
...ee.licenseInfo,
|
|
||||||
projectHash: hashProject(strapi),
|
|
||||||
dependencyHash: hashDep(strapi),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: { 'x-strapi-project': 'enterprise' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!pingDisabled) {
|
|
||||||
const licenseCron = scheduleJob('0 0 0 * * 7', () => sendLicenseCheck());
|
|
||||||
crons.push(licenseCron);
|
|
||||||
|
|
||||||
sendLicenseCheck();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroy() {
|
destroy() {
|
||||||
// clear open handles
|
// Clear open handles
|
||||||
crons.forEach((cron) => cron.cancel());
|
crons.forEach((cron) => cron.cancel());
|
||||||
},
|
},
|
||||||
|
|
||||||
async send(event, payload) {
|
async send(event, payload) {
|
||||||
if (isDisabled) return true;
|
if (isDisabled) return true;
|
||||||
return sendEvent(event, payload);
|
return sendEvent(event, payload);
|
||||||
@ -89,24 +57,4 @@ const createTelemetryInstance = (strapi) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const hash = (str) => crypto.createHash('sha256').update(str).digest('hex');
|
|
||||||
|
|
||||||
const hashProject = (strapi) =>
|
|
||||||
hash(`${strapi.config.get('info.name')}${strapi.config.get('info.description')}`);
|
|
||||||
|
|
||||||
const hashDep = (strapi) => {
|
|
||||||
const depStr = JSON.stringify(strapi.config.info.dependencies);
|
|
||||||
const readmePath = path.join(strapi.dirs.app.root, 'README.md');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(readmePath)) {
|
|
||||||
return hash(`${depStr}${fs.readFileSync(readmePath)}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return hash(`${depStr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash(`${depStr}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = createTelemetryInstance;
|
module.exports = createTelemetryInstance;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user