diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index 2848764d17..323ba1d216 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -100,17 +100,15 @@ module.exports = { hooks: function (M, app) { return { beforeCreate: async (device, options) => { - // if the product is licensed, we permit overage - const isLicensed = app.license.active() - if (isLicensed !== true) { - const { devices } = await app.license.usage('devices') - if (devices.count >= devices.limit) { + if (app.license.active() && app.license.status().expired) { + throw new Error('license expired') + } + const { devices } = await app.license.usage('devices') + if (devices.count >= devices.limit) { + // Potential overage - check if overage is permitted by the license + if (!app.license.allowOverage('devices')) { throw new Error('license limit reached') } - } else { - if (app.license.status().expired) { - throw new Error('license expired') - } } }, afterCreate: async (device, options) => { diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 3398bad723..deae239481 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -144,11 +144,13 @@ module.exports = { hooks: function (M, app) { return { beforeCreate: async (project, opts) => { - // if the product is licensed, we permit overage - const isLicensed = app.license.active() - if (isLicensed !== true) { - const { instances } = await app.license.usage('instances') - if (instances.count >= instances.limit) { + if (app.license.active() && app.license.status().expired) { + throw new Error('license expired') + } + const { instances } = await app.license.usage('instances') + if (instances.count >= instances.limit) { + // Potential overage - check if overage is permitted by the license + if (!app.license.allowOverage('instances')) { throw new Error('license limit reached') } } diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 04c29f221c..1ce2e03b20 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -1,72 +1,110 @@ const fp = require('fastify-plugin') -module.exports = fp(async function (app, opts) { - if (app.config.billing) { - app.decorate('billing', await require('./billing').init(app)) +// common features across all higher tier (enterpise, hub, edge, fleet) +async function commonFeatures (app, opts) { + app.decorate('sso', await require('./sso').init(app)) + // Set the MFA Feature Flag + app.config.features.register('mfa', true, true) + // Set the Project History timeline Feature Flag + app.config.features.register('projectHistory', true, true) + if (app.config.npmRegistry?.enabled) { + // Set npm Feature Flag + app.config.features.register('npm', true, true) } - require('./projectComms').init(app) - require('./deviceEditor').init(app) - require('./alerts').init(app) + if (app.config.tables?.enabled) { + app.decorate('tables', await require('./tables').init(app)) + } + app.config.features.register('certifiedNodes', true, true) + app.config.features.register('ffNodes', true, true) + // Application level RBAC + app.config.features.register('rbacApplication', true, true) + require('./autoUpdateStacks').init(app) - if (app.license.get('tier') === 'enterprise') { - require('./ha').init(app) - require('./protectedInstance').init(app) - require('./customHostnames').init(app) - app.decorate('sso', await require('./sso').init(app)) - await require('./teamBroker').init(app) - app.decorate('gitops', await require('./gitops').init(app)) - // Set the MFA Feature Flag - app.config.features.register('mfa', true, true) - // Set the Device Groups Feature Flag - app.config.features.register('deviceGroups', true, true) - // Set the Project History timeline Feature Flag - app.config.features.register('projectHistory', true, true) - // Set the Bill of Materials Feature Flag - app.config.features.register('bom', true, true) - if (app.config.npmRegistry?.enabled) { - // Set npm Feature Flag - app.config.features.register('npm', true, true) - } - if (app.config.tables?.enabled) { - app.decorate('tables', await require('./tables').init(app)) - } - app.config.features.register('certifiedNodes', true, true) - app.config.features.register('ffNodes', true, true) - app.config.features.register('rbacApplication', true, true) - require('./autoUpdateStacks').init(app) + // Expert + await app.register(require('./expert')) - // Expert - await app.register(require('./expert')) + // Set the AI Features Flag (global gate for all AI features) + const isAiEnabled = !!(app.config?.ai?.enabled ?? true) + app.config.features.register('ai', isAiEnabled, true) - // Set the AI Features Flag (global gate for all AI features) - const isAiEnabled = !!(app.config?.ai?.enabled ?? true) - app.config.features.register('ai', isAiEnabled, true) + // Set the Generate Snapshot Description Feature Flag + const isAssistantConfigured = isAiEnabled && app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url + app.config.features.register('generatedSnapshotDescription', isAssistantConfigured, true) - // Set the Generate Snapshot Description Feature Flag - const isAssistantConfigured = isAiEnabled && app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url - app.config.features.register('generatedSnapshotDescription', isAssistantConfigured, true) + // Set the assistant inline completions Feature Flag + app.config.features.register('assistantInlineCompletions', isAssistantConfigured, true) - // Set the assistant inline completions Feature Flag - app.config.features.register('assistantInlineCompletions', isAssistantConfigured, true) + // Set the expert platform automation Feature Flag (MCP platform tools server) + app.config.features.register('expertPlatformAutomation', isAiEnabled && (app.config?.expert?.enabled ?? false), true) - // Set the expert platform automation Feature Flag (MCP platform tools server) - app.config.features.register('expertPlatformAutomation', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // Set the expert assistant Feature Flag + app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true) - // Set the expert assistant Feature Flag - app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // Set the Expert Insights flag + const isInsightsEnabled = isAiEnabled && + !!app.config?.expert?.enabled && + (Object.prototype.hasOwnProperty.call(app.config?.expert ?? {}, 'insights') ? !!app.config?.expert?.insights?.enabled : true) - // Set the Expert Insights flag - const isInsightsEnabled = isAiEnabled && - !!app.config?.expert?.enabled && - (Object.prototype.hasOwnProperty.call(app.config?.expert ?? {}, 'insights') ? !!app.config?.expert?.insights?.enabled : true) + app.config.features.register('expertInsights', isInsightsEnabled ?? false, true) - app.config.features.register('expertInsights', isInsightsEnabled ?? false, true) + // Set the MCP third-party agent access flag. Defaults to true when AI + // and expert are both enabled, so it activates automatically unless + // explicitly disabled in the config. + const isMcpThirdPartyEnabled = isAiEnabled && (app.config?.expert?.enabled ?? false) + app.config.features.register('mcpThirdParty', isMcpThirdPartyEnabled, true) +} - // Set the MCP third-party agent access flag. Defaults to true when AI - // and expert are both enabled, so it activates automatically unless - // explicitly disabled in the config. - const isMcpThirdPartyEnabled = isAiEnabled && (app.config?.expert?.enabled ?? false) - app.config.features.register('mcpThirdParty', isMcpThirdPartyEnabled, true) +module.exports = fp(async function (app, opts) { + if (app.config.billing) { + app.decorate('billing', await require('./billing').init(app)) + } + // common features for all license levels (inc Pro) + require('./projectComms').init(app) + require('./deviceEditor').init(app) + require('./alerts').init(app) + if (app.license.get('tier') && (app.license.get('version') === '' || app.license.get('version') === '2024-03-04')) { + app.config.features.register('remoteInstances', true, true) + if (app.license.get('tier') === 'enterprise') { + await commonFeatures(app, opts) + // HA + require('./ha').init(app) + // Protected Instances + require('./protectedInstance').init(app) + // Git Opps + app.decorate('gitops', await require('./gitops').init(app)) + require('./customHostnames').init(app) + await require('./teamBroker').init(app) + + // Set the Device Groups Feature Flag + app.config.features.register('deviceGroups', true, true) + // Set the Bill of Materials Feature Flag + app.config.features.register('bom', true, true) + } else { + // old "Pro" license + } + } else if (app.license.get('tiers') && app.license.get('version') === '2026-08-20') { + const tiers = app.license.get('tiers') + await commonFeatures(app, opts) + + if (tiers.includes('hub')) { + // `hub` does not include remote instances so we disable the feature flag for it + // - it will get re-enabled below if the license also includes `edge` or `fleet` + app.config.features.register('remoteInstances', false, true) + // HA + require('./ha').init(app) + // Protected Instances + require('./protectedInstance').init(app) + // Git Opps + app.decorate('gitops', await require('./gitops').init(app)) + // Set the Bill of Materials Feature Flag + app.config.features.register('bom', true, true) + } + if (tiers.includes('edge') || tiers.includes('fleet')) { + app.config.features.register('remoteInstances', true, true) + // Set the Device Groups Feature Flag + app.config.features.register('deviceGroups', true, true) + await require('./teamBroker').init(app) + } } // Set the Team Library Feature Flag diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index 14ef4c5b03..1fd1a04881 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -4,45 +4,79 @@ * @namespace api * @memberof forge.ee */ + +// common features across all higher tier (enterpise, hub, edge, fleet) +async function commonFeatures (app) { + await app.register(require('./expert'), { prefix: '/api/v1/expert', logLevel: app.config.logging.http }) + await app.register(require('./mcp'), { logLevel: app.config.logging.http }) + if (app.config.npmRegistry?.enabled) { + await app.register(require('./catalogues'), { prefix: '/api/v1/teams/:teamId', logLevel: app.config.logging.http }) + } + await app.register(require('./mfa'), { prefix: '/api/v1', logLevel: app.config.logging.http }) + if (app.config.tables?.enabled) { + await app.register(require('./tables'), { prefix: '/api/v1/teams/:teamId/databases', logLevel: app.config.logging.http }) + } + await app.register(require('./resource'), { prefix: '/api/v1/projects/:instanceId/resources', logLevel: app.config.logging.http }) + await app.register(require('./autoUpdateStacks'), { prefix: '/api/v1/projects/:projectId/autoUpdateStack', logLevel: app.config.logging.http }) + await app.register(require('./httpTokens'), { prefix: '/api/v1', logLevel: app.config.logging.http }) + await app.register(require('./staticAssets'), { prefix: '/api/v1/projects/:projectId/files', logLevel: app.config.logging.http }) + await app.register(require('./projectHistory'), { prefix: '/api/v1/projects/:instanceId/history', logLevel: app.config.logging.http }) + await app.register(require('./deviceHistory'), { prefix: '/api/v1/devices/:deviceId/history', logLevel: app.config.logging.http }) +} + module.exports = async function (app) { app.addHook('preHandler', app.verifySession) if (app.config.billing) { await app.register(require('./billing'), { prefix: '/ee/billing', logLevel: app.config.logging.http }) } + // Features provied to any license level (inc pro) await app.register(require('./sharedLibrary'), { logLevel: app.config.logging.http }) await app.register(require('./pipeline'), { prefix: '/api/v1', logLevel: app.config.logging.http }) await app.register(require('./pipeline/teamPipelines.js'), { prefix: '/api/v1/teams/:teamId/pipelines', logLevel: app.config.logging.http }) await app.register(require('./deviceEditor'), { prefix: '/api/v1/devices/:deviceId/editor', logLevel: app.config.logging.http }) - await app.register(require('./bom/application.js'), { prefix: '/api/v1/applications', logLevel: app.config.logging.http }) - await app.register(require('./bom/team.js'), { prefix: '/api/v1/teams', logLevel: app.config.logging.http }) await app.register(require('./flowBlueprints'), { prefix: '/api/v1/flow-blueprints', logLevel: app.config.logging.http }) - if (app.license.get('tier') === 'enterprise') { - await app.register(require('./applicationDeviceGroups'), { prefix: '/api/v1/applications/:applicationId/device-groups', logLevel: app.config.logging.http }) - await app.register(require('./teamDeviceGroups'), { prefix: '/api/v1/teams/:teamId/device-groups', logLevel: app.config.logging.http }) - await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http }) - await app.register(require('./protectedInstance'), { prefix: '/api/v1/projects/:projectId/protectInstance', logLevel: app.config.logging.http }) - await app.register(require('./mfa'), { prefix: '/api/v1', logLevel: app.config.logging.http }) - await app.register(require('./httpTokens'), { prefix: '/api/v1', logLevel: app.config.logging.http }) - await app.register(require('./customHostnames'), { prefix: '/api/v1/projects/:projectId/customHostname', logLevel: app.config.logging.http }) - await app.register(require('./staticAssets'), { prefix: '/api/v1/projects/:projectId/files', logLevel: app.config.logging.http }) - await app.register(require('./projectHistory'), { prefix: '/api/v1/projects/:instanceId/history', logLevel: app.config.logging.http }) - await app.register(require('./deviceHistory'), { prefix: '/api/v1/devices/:deviceId/history', logLevel: app.config.logging.http }) - await app.register(require('./teamBroker'), { prefix: '/api/v1/teams/:teamId/broker', logLevel: app.config.logging.http }) - await app.register(require('./teamBroker/3rdPartyBroker'), { prefix: '/api/v1/teams/:teamId/brokers', logLevel: app.config.logging.http }) - if (app.config.npmRegistry?.enabled) { - await app.register(require('./catalogues'), { prefix: '/api/v1/teams/:teamId', logLevel: app.config.logging.http }) + let enableSSO = false + if (app.license.get('tier') && (app.license.get('version') === '' || app.license.get('version') === '2024-03-04')) { + if (app.license.get('tier') === 'enterprise') { + await commonFeatures(app) + await app.register(require('./teamBroker'), { prefix: '/api/v1/teams/:teamId/broker', logLevel: app.config.logging.http }) + await app.register(require('./teamBroker/3rdPartyBroker'), { prefix: '/api/v1/teams/:teamId/brokers', logLevel: app.config.logging.http }) + await app.register(require('./applicationDeviceGroups'), { prefix: '/api/v1/applications/:applicationId/device-groups', logLevel: app.config.logging.http }) + await app.register(require('./teamDeviceGroups'), { prefix: '/api/v1/teams/:teamId/device-groups', logLevel: app.config.logging.http }) + await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http }) + await app.register(require('./protectedInstance'), { prefix: '/api/v1/projects/:projectId/protectInstance', logLevel: app.config.logging.http }) + await app.register(require('./gitops'), { prefix: '/api/v1/teams/:teamId/git', logLevel: app.config.logging.http }) + await app.register(require('./bom/application.js'), { prefix: '/api/v1/applications', logLevel: app.config.logging.http }) + await app.register(require('./bom/team.js'), { prefix: '/api/v1/teams', logLevel: app.config.logging.http }) + await app.register(require('./customHostnames'), { prefix: '/api/v1/projects/:projectId/customHostname', logLevel: app.config.logging.http }) + enableSSO = true + } else { + // old Pro license + } + } else if (app.license.get('tiers') && app.license.get('version') === '2026-08-20') { + const tiers = app.license.get('tiers') + await commonFeatures(app) + enableSSO = true + + if (tiers.includes('hub')) { + await app.register(require('./gitops'), { prefix: '/api/v1/teams/:teamId/git', logLevel: app.config.logging.http }) + await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http }) + await app.register(require('./protectedInstance'), { prefix: '/api/v1/projects/:projectId/protectInstance', logLevel: app.config.logging.http }) + await app.register(require('./bom/application.js'), { prefix: '/api/v1/applications', logLevel: app.config.logging.http }) + await app.register(require('./bom/team.js'), { prefix: '/api/v1/teams', logLevel: app.config.logging.http }) + await app.register(require('./customHostnames'), { prefix: '/api/v1/projects/:projectId/customHostname', logLevel: app.config.logging.http }) } - await app.register(require('./gitops'), { prefix: '/api/v1/teams/:teamId/git', logLevel: app.config.logging.http }) - await app.register(require('./resource'), { prefix: '/api/v1/projects/:instanceId/resources', logLevel: app.config.logging.http }) - if (app.config.tables?.enabled) { - await app.register(require('./tables'), { prefix: '/api/v1/teams/:teamId/databases', logLevel: app.config.logging.http }) + if (tiers.includes('edge') || tiers.includes('fleet')) { + await app.register(require('./teamBroker'), { prefix: '/api/v1/teams/:teamId/broker', logLevel: app.config.logging.http }) + await app.register(require('./teamBroker/3rdPartyBroker'), { prefix: '/api/v1/teams/:teamId/brokers', logLevel: app.config.logging.http }) + await app.register(require('./applicationDeviceGroups'), { prefix: '/api/v1/applications/:applicationId/device-groups', logLevel: app.config.logging.http }) + await app.register(require('./teamDeviceGroups'), { prefix: '/api/v1/teams/:teamId/device-groups', logLevel: app.config.logging.http }) } - await app.register(require('./mcp'), { logLevel: app.config.logging.http }) - await app.register(require('./autoUpdateStacks'), { prefix: '/api/v1/projects/:projectId/autoUpdateStack', logLevel: app.config.logging.http }) - await app.register(require('./expert'), { prefix: '/api/v1/expert', logLevel: app.config.logging.http }) + } + if (enableSSO) { // Important: keep SSO last to avoid its error handling polluting other routes. await app.register(require('./sso'), { logLevel: app.config.logging.http }) } diff --git a/forge/licensing/index.js b/forge/licensing/index.js index cfbc01f6e0..c314366abd 100644 --- a/forge/licensing/index.js +++ b/forge/licensing/index.js @@ -83,6 +83,7 @@ module.exports = fp(async function (app, opts) { status: () => { return status() }, + allowOverage, defaults: defaultLimits } @@ -175,6 +176,22 @@ module.exports = fp(async function (app, opts) { return usage } + function allowOverage (resource) { + if (!activeLicense) { + // No overage if no license is applied + return false + } else if (activeLicense.tiers) { + // In general overage is permitted. + // However, if the license is hub only, then we should not allow overage for remote instances (devices) if the limit is 0. + // This prevents a hub-only license from being used to run a fleet of remote devices. + if (resource === 'devices' && (!activeLicense.tiers.includes('edge') || activeLicense.tiers.includes('fleet')) && licenseApi.get('devices') === 0) { + return false + } + } + // Otherwise, overage is permitted + return true + } + async function reportUsage () { const { users, teams, devices, instances, mqttClients } = await usage() const logUse = (name, count, limit) => { @@ -204,14 +221,18 @@ module.exports = fp(async function (app, opts) { app.log.info(` License ID : ${activeLicense.id}`) app.log.info(` Org : ${activeLicense.organisation}`) app.log.info(` Valid From : ${activeLicense.validFrom.toISOString()}`) - app.log.info(` License Tier : ${activeLicense.tier}`) + if (activeLicense.tier) { + app.log.info(` License Tier : ${activeLicense.tier}`) + } else if (activeLicense.tiers) { + app.log.info(` License Entitlements : ${activeLicense.tiers.toString()}`) + } if (activeLicense.expired) { app.log.warn(` Expired : ${activeLicense.expiresAt.toISOString()}`) } else { app.log.info(` Expires : ${activeLicense.expiresAt.toISOString()}`) } if (licenseApi.get('instances') === undefined) { - // pre 2.2 license that does not combine instance and device counts + // < 2.2 || >= 3.0 license that does not combine instance and device counts licenseModeCombinedInstances = false } else { licenseModeCombinedInstances = true diff --git a/forge/licensing/license-generator-old.js b/forge/licensing/license-generator-old.js new file mode 100644 index 0000000000..904b7dd67e --- /dev/null +++ b/forge/licensing/license-generator-old.js @@ -0,0 +1,137 @@ +// This is a command-line tool used to generate valid FlowFuse license files. + +// A license file is encoded as a JSON Web Token signed using ES256 +// It consists of a well-defined set of claims. These claims identify who +// the license is for, when it was created and when it expires. +// It also includes claims relating to what the license entitles the user to do +// with the platform. + +// Currently, there is both a development public and private key in this repository. +// These are completely insecure keys to be used at this very early stage of +// development - they are used by the tests to generate and verify licenses. +// +// To generate a production license, you will need to access the Production private key +// file in the FlowFuse 1Password vault + +// ref: https://www.scottbrady91.com/OpenSSL/Creating-Elliptical-Curve-Keys-using-OpenSSL + +// 1. generate an ES256 key pair +// openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem +// 2. encrypt it with a passphrase +// openssl ec -in private-key.pem -out private-key_enc.pem -aes256 +// 3. extract the public key +// openssl ec -in private-key_enc.pem -pubout -out public-key.pem + +// To generate a license, update the 'licenseDetails' object below and run this +// file directly. + +const fs = require('fs') + +const jwt = require('jsonwebtoken') +const promptly = require('promptly') +const { v4: uuidv4 } = require('uuid') + +;(async () => { + console.info('FlowFuse EE License Generator') + console.info('------------------------------') + try { + const devLicense = await promptly.confirm('Is this a development-only license? (Y/n): ', { default: 'y' }) + + const key = devLicense + ? fs.readFileSync('dev-private-key_enc.pem') + : await promptly.prompt('Production license private key filename: ', { + validator: (value) => { + if (!fs.existsSync(value)) { + throw new Error('Private key file not found') + } + return fs.readFileSync(value) + } + }) + + const passphrase = devLicense + ? 'password' + : await promptly.password('Passphrase: ', { + replace: '*' + }) + + const licenseTier = await promptly.choose('License tier (teams, enterprise, hub*, edge, fleet): ', ['teams', 'enterprise', 'hub', 'edge', 'fleet'], { default: 'hub', trim: true }) + + const licenseHolder = await promptly.prompt('License holder name: ') + + const maxUsers = parseInt(await promptly.prompt('Max allowed users: ', { default: '5' })) + const maxTeams = parseInt(await promptly.prompt('Max allowed teams: ', { default: '5' })) + const maxInstances = parseInt(await promptly.prompt('Max allowed instances (hosted + devices): ', { default: '5' })) + const maxMQTTClients = parseInt(await promptly.prompt('Max allowed MQTT Clients: ', { default: '20' })) + + const licenseNotes = devLicense + ? 'Development-mode Only. Not for production' + : await promptly.prompt('License notes: ', { default: '' }) + + const today = new Date().toISOString().substring(0, 10) + const validFrom = await promptly.prompt(`Valid from [${today}]: `, { + default: today, + validator: (value) => { + const date = new Date(value) + if (isNaN(date.getTime())) { + throw new Error('Invalid start time') + } + return Math.floor(date.getTime() / 1000) + } + }) + + const defaultExpire = new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().substring(0, 10) + const expiry = await promptly.prompt(`Expire at [${defaultExpire}]: `, { + default: defaultExpire, + validator: (value) => { + const date = new Date(value) + if (isNaN(date.getTime())) { + throw new Error('Invalid expire time') + } + return Math.floor(date.getTime() / 1000) + } + }) + + // generate a random license id (uuid) + const licenseId = uuidv4() + + const licenseDetails = { + id: licenseId, + ver: '2024-03-04', // Used to determined the format of the license. + iss: 'FlowForge Inc.', // DO NOT CHANGE + sub: licenseHolder, // Name of the license holder + nbf: validFrom, + exp: expiry, // Expiry of the license in epoch seconds + note: licenseNotes, // Freeform text to associate with license + users: maxUsers, + teams: maxTeams, + instances: maxInstances, + mqttClients: maxMQTTClients, + tier: licenseTier + } + + if (devLicense) { + licenseDetails.dev = true + } + + const licenseText = jwt.sign( + licenseDetails, + { key, passphrase }, + { algorithm: 'ES256' } + ) + console.info() + console.info('License Details:') + console.info(JSON.stringify(licenseDetails, ' ', 4)) + console.info('License:') + console.info('---') + console.info(licenseText) + console.info('---') + } catch (err) { + if (err.code === 'ERR_OSSL_EVP_BAD_DECRYPT') { + console.warn('Error generating license: bad passphrase') + } else if (err.code === 'TIMEDOUT') { + // Ctrl-C.. exit quietly + } else { + console.warn(err) + } + } +})() diff --git a/forge/licensing/license-generator.js b/forge/licensing/license-generator.js index fdd69dd655..2554a51cd3 100644 --- a/forge/licensing/license-generator.js +++ b/forge/licensing/license-generator.js @@ -1,121 +1,184 @@ -// This is a command-line tool used to generate valid FlowFuse license files. - -// A license file is encoded as a JSON Web Token signed using ES256 -// It consists of a well-defined set of claims. These claims identify who -// the license is for, when it was created and when it expires. -// It also includes claims relating to what the license entitles the user to do -// with the platform. - -// Currently, there is both a development public and private key in this repository. -// These are completely insecure keys to be used at this very early stage of -// development - they are used by the tests to generate and verify licenses. -// -// To generate a production license, you will need to access the Production private key -// file in the FlowFuse 1Password vault +const fs = require('fs') -// ref: https://www.scottbrady91.com/OpenSSL/Creating-Elliptical-Curve-Keys-using-OpenSSL +const inquirer = require('inquirer').default +const jwt = require('jsonwebtoken') +const { v4: uuidv4 } = require('uuid') -// 1. generate an ES256 key pair -// openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem -// 2. encrypt it with a passphrase -// openssl ec -in private-key.pem -out private-key_enc.pem -aes256 -// 3. extract the public key -// openssl ec -in private-key_enc.pem -pubout -out public-key.pem +// import {v4 as uuidv4, validate} from 'uuid' +// import jwt from 'jsonwebtoken' +// import inquirer from 'inquirer' +// import fs from 'fs' -// To generate a license, update the 'licenseDetails' object below and run this -// file directly. +const DEFAULT_DEV_KEY_FILENAME = 'dev-private-key_enc.pem' +const DEFAULT_PROD_KEY_FILENAME = 'flowforge-ee-private-key_enc.pem' -const fs = require('fs') +const today = new Date().toISOString().substring(0, 10) +const defaultExpire = new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().substring(0, 10) +const licenseId = uuidv4() -const jwt = require('jsonwebtoken') -const promptly = require('promptly') -const { v4: uuidv4 } = require('uuid') +console.info('FlowFuse EE License Generator') +console.info('------------------------------') ;(async () => { - console.info('FlowFuse EE License Generator') - console.info('------------------------------') try { - const devLicense = await promptly.confirm('Is this a development-only license? (Y/n): ', { default: 'y' }) - - const key = devLicense - ? fs.readFileSync('dev-private-key_enc.pem') - : await promptly.prompt('Production license private key filename: ', { - validator: (value) => { - if (!fs.existsSync(value)) { - throw new Error('Private key file not found') + const initialAnswers = await inquirer.prompt([ + { + name: 'devLicense', + default: true, + type: 'confirm', + message: 'Development Only License' + }, { + name: 'tiers', + type: 'checkbox', + choices: [{ name: 'Hub', value: 'hub' }, { name: 'Edge', value: 'edge' }, { name: 'Fleet', value: 'fleet' }], + message: 'What license types should be included?', + validate (input) { + if (input.length > 0) { + return true + } else { + return 'Must select at least one' } - return fs.readFileSync(value) } - }) - - const passphrase = devLicense - ? 'password' - : await promptly.password('Passphrase: ', { - replace: '*' - }) - - const licenseTier = await promptly.choose('License tier (teams*, enterprise): ', ['teams', 'enterprise'], { default: 'teams', trim: true }) - - const licenseHolder = await promptly.prompt('License holder name: ') - - const maxUsers = parseInt(await promptly.prompt('Max allowed users: ', { default: '5' })) - const maxTeams = parseInt(await promptly.prompt('Max allowed teams: ', { default: '5' })) - const maxInstances = parseInt(await promptly.prompt('Max allowed instances (hosted + devices): ', { default: '5' })) - const maxMQTTClients = parseInt(await promptly.prompt('Max allowed MQTT Clients: ', { default: '20' })) - - const licenseNotes = devLicense - ? 'Development-mode Only. Not for production' - : await promptly.prompt('License notes: ', { default: '' }) + }, { + name: 'licenseHolder', + type: 'input', + message: 'License holder name', + validate (input) { + if (input.length > 0) { + return true + } - const today = new Date().toISOString().substring(0, 10) - const validFrom = await promptly.prompt(`Valid from [${today}]: `, { - default: today, - validator: (value) => { - const date = new Date(value) - if (isNaN(date.getTime())) { - throw new Error('Invalid start time') + return 'License holder name must be entered' } - return Math.floor(date.getTime() / 1000) + }, { + name: 'maxUsers', + type: 'number', + default: 5, + message: 'Max allowed Users' + }, { + name: 'maxTeams', + type: 'number', + default: 5, + message: 'Max allowed Teams' } - }) - - const defaultExpire = new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().substring(0, 10) - const expiry = await promptly.prompt(`Expire at [${defaultExpire}]: `, { - default: defaultExpire, - validator: (value) => { - const date = new Date(value) - if (isNaN(date.getTime())) { - throw new Error('Invalid expire time') + ]) + // Now we have the tier selection, we can set the defaults for the other questions based on the selected tiers. + const tierDefaults = { + hosted: 10, + remote: 10 + } + if (!initialAnswers.tiers.includes('edge') && !initialAnswers.tiers.includes('fleet')) { + tierDefaults.remote = 0 + } + const moreAnswers = await inquirer.prompt([ + { + name: 'maxHostedInstances', + type: 'number', + default: tierDefaults.hosted, + message: 'Max allowed Hosted Instances' + }, { + name: 'maxRemoteInstances', + type: 'number', + default: tierDefaults.remote, + message: 'Max allowed Remote Instances' + }, { + name: 'maxMQTTClients', + type: 'number', + default: 20, + message: 'Max allowed MQTT client' + }, { + name: 'notes', + type: 'input', + message: 'License notes' + }, { + name: 'validFrom', + type: 'input', + default: today, + message: 'Valid from', + validate (input) { + const date = new Date(input) + if (isNaN(date.getTime())) { + return 'Invalid start time' + } + return true + }, + filter (input, hash) { + const date = new Date(input) + return Math.floor(date.getTime() / 1000) + } + }, { + name: 'expiry', + type: 'input', + default: defaultExpire, + message: 'Expire at', + validate (input) { + const date = new Date(input) + if (isNaN(date.getTime())) { + return 'Invalid expire time' + } + return true + }, + filter (input, hash) { + const date = new Date(input) + return Math.floor(date.getTime() / 1000) } - return Math.floor(date.getTime() / 1000) } - }) - - // generate a random license id (uuid) - const licenseId = uuidv4() - + ]) + const answers = { ...initialAnswers, ...moreAnswers } const licenseDetails = { id: licenseId, - ver: '2024-03-04', // Used to determined the format of the license. + // ver: '2024-03-04', // Used to determined the format of the license. + ver: '2026-08-20', // Used to determined the format of the license. iss: 'FlowForge Inc.', // DO NOT CHANGE - sub: licenseHolder, // Name of the license holder - nbf: validFrom, - exp: expiry, // Expiry of the license in epoch seconds - note: licenseNotes, // Freeform text to associate with license - users: maxUsers, - teams: maxTeams, - instances: maxInstances, - mqttClients: maxMQTTClients, - tier: licenseTier + sub: answers.licenseHolder, // Name of the license holder + nbf: answers.validFrom, + exp: answers.expiry, // Expiry of the license in epoch seconds + note: answers.licenseNotes, // Freeform text to associate with license + users: answers.maxUsers, + teams: answers.maxTeams, + projects: answers.maxHostedInstances, + devices: answers.maxRemoteInstances, + mqttClients: answers.maxMQTTClients, + tiers: answers.tiers // licenseTier } - if (devLicense) { + const keyQuestions = [] + + if (answers.devLicense) { licenseDetails.dev = true + // Use DEFAULT_DEV_KEY_FILENAME + } else { + // Prompt for the private key filename and password to use for signing the license + keyQuestions.push({ + name: 'key', + type: 'input', + default: DEFAULT_PROD_KEY_FILENAME, + message: 'Production license private key filename', + validate (input) { + if (!fs.existsSync(input)) { + return true + } + return false + }, + filter (input, answers) { + return fs.readFileSync(input) + } + }) } + keyQuestions.push({ + name: 'passphrase', + type: 'password', + mask: true, + message: 'Password for private key' + }) + + const keyAnswers = await inquirer.prompt(keyQuestions) + const key = keyAnswers.key ? keyAnswers.key : fs.readFileSync(DEFAULT_DEV_KEY_FILENAME) + const licenseText = jwt.sign( licenseDetails, - { key, passphrase }, + { key, passphrase: keyAnswers.passphrase }, { algorithm: 'ES256' } ) console.info() @@ -126,12 +189,6 @@ const { v4: uuidv4 } = require('uuid') console.info(licenseText) console.info('---') } catch (err) { - if (err.code === 'ERR_OSSL_EVP_BAD_DECRYPT') { - console.warn('Error generating license: bad passphrase') - } else if (err.code === 'TIMEDOUT') { - // Ctrl-C.. exit quietly - } else { - console.warn(err) - } + console.info(err) } })() diff --git a/forge/licensing/loader.js b/forge/licensing/loader.js index 83ef67d3bf..dbb8cc5c9c 100644 --- a/forge/licensing/loader.js +++ b/forge/licensing/loader.js @@ -22,7 +22,14 @@ class LicenseDetails { this.validFrom = new Date(claims.nbf * 1000) this.expiresAt = new Date(claims.exp * 1000) this.dev = claims.dev - this.tier = claims.tier || 'enterprise' + if (claims.ver === '2024-03-04') { + this.tier = claims.tier || 'enterprise' + } else if (claims.ver === '2026-08-20') { + this.tiers = claims.tiers + } else if (claims.ver === undefined) { + // legacy long lived test development licenses + this.tier = claims.tier || 'enterprise' + } this.users = claims.users || 0 if (Object.hasOwn(claims, 'instances')) { this.instances = claims.instances || 0 diff --git a/frontend/src/components/DevicesBrowser.vue b/frontend/src/components/DevicesBrowser.vue index 262d7fa584..241c36140a 100644 --- a/frontend/src/components/DevicesBrowser.vue +++ b/frontend/src/components/DevicesBrowser.vue @@ -6,6 +6,7 @@ class="flex-1" />