UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

877 lines (841 loc) • 41.9 kB
/** * A Device * @namespace forge.db.models.Device */ const crypto = require('crypto') const SemVer = require('semver') const { col, fn, DataTypes, Op, where, literal } = require('sequelize') const Controllers = require('../controllers') const { generateToken, buildPaginationSearchClause } = require('../utils') const ALLOWED_SETTINGS = { autoSnapshot: 1, editor: 1, env: 1, palette: 1, security: 1 } const DEFAULT_SETTINGS = { autoSnapshot: true, editor: { nodeRedVersion: null } } module.exports = { name: 'Device', schema: { name: { type: DataTypes.STRING, allowNull: false }, type: { type: DataTypes.STRING, allowNull: false }, credentialSecret: { type: DataTypes.STRING, allowNull: false }, state: { type: DataTypes.STRING, allowNull: false, defaultValue: '' }, lastSeenAt: { type: DataTypes.DATE, allowNull: true }, settingsHash: { type: DataTypes.STRING, allowNull: true }, agentVersion: { type: DataTypes.STRING, allowNull: true }, nodeRedVersion: { type: DataTypes.STRING, allowNull: true }, mode: { type: DataTypes.STRING, allowNull: true, defaultValue: 'autonomous' }, editorAffinity: { type: DataTypes.STRING, defaultValue: '' }, editorToken: { type: DataTypes.STRING, defaultValue: '' }, editorConnected: { type: DataTypes.BOOLEAN, defaultValue: false }, /** @type {'instance'|'application'|null} a virtual column that signifies the parent type e.g. `"instance"`, `"application"` */ ownerType: { type: DataTypes.VIRTUAL(DataTypes.ENUM('instance', 'application', null)), get () { return this.ProjectId ? 'instance' : (this.ApplicationId ? 'application' : null) } }, isApplicationOwned: { type: DataTypes.VIRTUAL(DataTypes.BOOLEAN), get () { return this.ownerType === 'application' } } }, associations: function (M) { this.belongsTo(M.Application) this.belongsTo(M.Team) this.belongsTo(M.Project) this.hasOne(M.AccessToken, { foreignKey: 'ownerId', constraints: false, scope: { ownerType: 'device' } }) this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' }) this.belongsTo(M.ProjectSnapshot, { as: 'activeSnapshot' }) this.hasMany(M.DeviceSettings) this.hasMany(M.ProjectSnapshot) // associate device at application level with snapshots this.belongsTo(M.DeviceGroup, { foreignKey: { allowNull: true } }) // SEE: forge/db/models/DeviceGroup.js for the other side of this relationship this.hasOne(M.TeamBrokerClient, { foreignKey: 'ownerId', constraints: false, scope: { ownerType: 'device' } }) // Also hasOne AuthClient (for ff-auth) - but not adding the association as we don't // want the sequelize mixins to be added to the Device model - they don't // handle the casting from int to string for the deviceId/ownerId }, 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) { throw new Error('license limit reached') } } else { if (app.license.status().expired) { throw new Error('license expired') } } }, afterCreate: async (device, options) => { const { devices } = await app.license.usage('devices') if (devices.count > devices.limit) { await app.auditLog.Platform.platform.license.overage('system', null, devices) } }, afterSave: async (device, options) => { // since `id`, `name` and `type` are added as FF_DEVICE_xx env vars, we // should update the settings checksum if they are modified // Additionally, since changing the target snapshot or DeviceGroupId // can affect the FF_ env vars, we should update the checksum if (device.changed('name') || device.changed('type') || device.changed('id') || device.changed('targetSnapshotId') || device.changed('DeviceGroupId')) { const updated = await device.updateSettingsHash() if (updated) { await device.save({ hooks: false, transaction: options.transaction }) } } }, beforeBulkUpdate: async (options) => { if (options.fields.includes('targetSnapshotId') || options.fields.includes('DeviceGroupId')) { if (!options.fields.includes('settingsHash')) { // Any update to targetSnapshot or DeviceGroup implicitly triggers a settings hash change. options.attributes.settingsHash = generateToken(16) options.fields.push('settingsHash') } } }, afterDestroy: async (device, opts) => { await M.AccessToken.destroy({ where: { ownerType: 'device', ownerId: '' + device.id } }) await M.AccessToken.destroy({ where: { ownerType: 'npm', ownerId: { [Op.like]: `d-${device.hashid}@%` } } }) await M.DeviceSettings.destroy({ where: { DeviceId: device.id } }) await M.BrokerClient.destroy({ where: { ownerType: 'device', ownerId: '' + device.id } }) await M.AuthClient.destroy({ where: { ownerType: 'device', ownerId: '' + device.id } }) // delete auto created team broker client await M.TeamBrokerClient.destroy({ where: { ownerType: 'device', ownerId: '' + device.id } }) // if MCPRegistration model is available (EE mode), remove any registrations for this device if (app.db.models.MCPRegistration?.destroy) { try { await app.db.models.MCPRegistration.destroy({ where: { targetType: 'device', targetId: '' + device.id } }) } catch (err) { // The destroy may fail if the DB connection is closed (e.g. during tests)! // Log the error but proceed as the instance has been deleted anyway app.log.error(`Error removing MCPRegistrations for deleted device ${device.id}: ${err.message}`) } } }, afterBulkDestroy: async (options) => { // Note: options.where may be empty, meaning all records are being deleted // however, since the bulk device deletion is initiated via a `where in [...]` clause, // we can assume that if options.where is empty, there are no devices to clean up. // i.e. only clean up if options.where contains an array `.id` if (!options.where || !options.where.id || !Array.isArray(options.where.id) || options.where.id.length === 0) { return } const deviceIds = [...options.where.id] const deviceIdsStrings = deviceIds.map(id => '' + id) // clean up related models await M.AccessToken.destroy({ where: { ownerType: 'device', ownerId: { [Op.in]: deviceIdsStrings } } }) await M.AccessToken.destroy({ where: { ownerType: 'npm', ownerId: { [Op.in]: deviceIds.map(id => `d-${M.Device.encodeHashid(id)}@%`) } } }) await M.DeviceSettings.destroy({ where: { DeviceId: { [Op.in]: deviceIds } } }) await M.BrokerClient.destroy({ where: { ownerType: 'device', ownerId: { [Op.in]: deviceIdsStrings } } }) await M.AuthClient.destroy({ where: { ownerType: 'device', ownerId: { [Op.in]: deviceIdsStrings } } }) await M.TeamBrokerClient.destroy({ where: { ownerType: 'device', ownerId: { [Op.in]: deviceIdsStrings } } }) // if MCPRegistration model is available (EE mode), remove any registrations for these devices if (app.db.models.MCPRegistration?.destroy) { try { await app.db.models.MCPRegistration.destroy({ where: { targetType: 'device', targetId: { [Op.in]: deviceIdsStrings } } }) } catch (err) { // The destroy may fail if the DB connection is closed (e.g. during tests)! // Log the error but proceed as the instance has been deleted anyway app.log.error(`Error removing MCPRegistrations for deleted devices ${deviceIdsStrings.join(', ')}: ${err.message}`) } } } } }, finders: function (M, app) { return { instance: { async refreshAuthTokens ({ refreshOTC = false } = {}) { const accessToken = await Controllers.AccessToken.createTokenForDevice(this) const credentialSecret = crypto.randomBytes(32).toString('hex') this.credentialSecret = credentialSecret await this.save() const result = { token: accessToken.token, credentialSecret } if (refreshOTC) { const oneTimeCode = await Controllers.AccessToken.createDeviceOTC(this) result.otc = oneTimeCode.otc } const broker = await Controllers.BrokerClient.createClientForDevice(this) if (broker) { // sync passwords: the mqtt nodes client connection uses the same password. this permits runtime mqtt connection without restart. await Controllers.TeamBrokerClient.updateNtMqttNodeUserPassword(this.TeamId, 'device', this.hashid, broker.password) result.broker = broker } return result }, async getAccessToken () { return M.AccessToken.findOne({ where: { ownerId: '' + this.id, ownerType: 'device', scope: 'device' } }) }, async getAuthClient () { // Cannot use a hasOne association as the resulting getAuthClient // mixin doesn't know to cast this.id to a string return M.AuthClient.findOne({ where: { ownerId: '' + this.id, ownerType: 'device' } }) }, async updateSettingsHash (settings) { const settingsHash = this.settingsHash const _settings = settings || await this.getAllSettings({ mergeDeviceGroupSettings: true }) delete _settings.autoSnapshot // autoSnapshot is not part of the settings hash this.settingsHash = hashSettings(_settings) return this.settingsHash !== settingsHash }, async getAllSettings (options = { mergeDeviceGroupSettings: false }) { const mergeDeviceGroupSettings = options.mergeDeviceGroupSettings || false const result = {} const settings = await this.getDeviceSettings() settings.forEach(setting => { result[setting.key] = setting.value }) // To compute the platform specific env vars, we need the associated // owner (application or instance) and the target snapshot model (if any) const reloadWith = [] if (this.isApplicationOwned && !this.Application) { reloadWith.push(M.Application) } if (this.targetSnapshotId && !this.targetSnapshot) { reloadWith.push({ model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'] }) } if (reloadWith.length) { await this.reload({ include: reloadWith }) } result.env = Controllers.Device.insertPlatformSpecificEnvVars(this, result.env) // add platform specific device env vars // if the device is a group member, we need to merge the group settings if (mergeDeviceGroupSettings && this.DeviceGroupId) { const group = this.DeviceGroup || await M.DeviceGroup.byId(this.DeviceGroupId) if (group) { const groupEnv = await group.settings.env || [] // Merge rule: If the device has an env var AND it has a value, it remains unchanged. // Otherwise, the value is taken from the group. // This is to allow the device to override a (global) group env setting. groupEnv.forEach(env => { const existing = result.env.find(e => e.name === env.name) if (!existing) { result.env.push(env) } else if (existing && !existing.value) { existing.value = env.value } }) } } if (!Object.prototype.hasOwnProperty.call(result, 'autoSnapshot')) { result.autoSnapshot = DEFAULT_SETTINGS.autoSnapshot } return result }, async updateSettings (obj) { const updates = [] for (let [key, value] of Object.entries(obj)) { if (ALLOWED_SETTINGS[key]) { if (key === 'env' && value && Array.isArray(value)) { value = Controllers.Device.removePlatformSpecificEnvVars(value) // remove platform specific values } updates.push({ DeviceId: this.id, key, value }) } } await M.DeviceSettings.bulkCreate(updates, { updateOnDuplicate: ['value'] }) await this.updateSettingsHash() await this.save() }, async updateSetting (key, value) { if (ALLOWED_SETTINGS[key]) { if (key === 'env' && value && Array.isArray(value)) { value = Controllers.Device.removePlatformSpecificEnvVars(value) // remove platform specific values } const result = await M.DeviceSettings.upsert({ DeviceId: this.id, key, value }) await this.updateSettingsHash() await this.save() return result } else { throw new Error(`Invalid device setting ${key}`) } }, async getSetting (key) { const result = await M.DeviceSettings.findOne({ where: { DeviceId: this.id, key } }) if (result) { if (key === 'env' && result.value && Array.isArray(result.value)) { return Controllers.Device.insertPlatformSpecificEnvVars(this, result.value) } return result.value } return DEFAULT_SETTINGS[key] }, async getLatestSnapshot (excludeAutoGenerated = false) { const snapshots = await this.getProjectSnapshots({ where: excludeAutoGenerated ? { name: { [Op.notLike]: 'Auto Snapshot - %' } } : {}, order: [['createdAt', 'DESC'], ['id', 'DESC']], limit: 1 }) return snapshots[0] }, getDefaultNodeRedVersion () { let nodeRedVersion = '3.0.2' // default to older Node-RED if (SemVer.satisfies(SemVer.coerce(this.agentVersion), '>=1.11.2')) { // 1.11.2 includes fix for ESM loading of GOT, so lets use 'latest' as before nodeRedVersion = 'latest' } return nodeRedVersion }, getDefaultModules () { return { 'node-red': this.getDefaultNodeRedVersion(), '@flowfuse/nr-project-nodes': '>0.5.0', // TODO: get this from the "settings" (future) '@flowfuse/nr-mqtt-nodes': '>=0.1.0', // TODO: get this from the "settings" (future) '@flowfuse/nr-tables-nodes': '>=0.1.0', // TODO: get this from the "settings" (future) '@flowfuse/nr-assistant': '>=0.1.0' } } }, static: { byId: async (id, options) => { options = Object.assign({ includeAssociations: true }, options) if (typeof id === 'string') { id = M.Device.decodeHashid(id) } let include = [] if (options.includeAssociations) { include = [ { model: M.Team, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId'] }, { model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] }, { model: M.Project, attributes: ['id', 'name', 'links'], include: { model: M.Application, attributes: ['id', 'name', 'links'] } }, { model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'] }, { model: M.ProjectSnapshot, as: 'activeSnapshot', attributes: ['id', 'hashid', 'name'] }, { model: M.DeviceGroup } ] } return this.findOne({ where: { id }, include }) }, byTeam: async (teamIdOrHash, { query = null, deviceId = null } = {}) => { let teamId = teamIdOrHash if (typeof teamId === 'string') { teamId = M.Team.decodeHashid(teamId) } const queryObject = { where: { [Op.and]: [{ TeamId: teamId }] } } if (deviceId) { queryObject.where[Op.and].push({ id: deviceId }) } else if (query) { queryObject.where[Op.and].push({ [Op.or]: [ where(fn('lower', col('Device.name')), { [Op.like]: `%${query.toLowerCase()}%` }), where(fn('lower', col('Device.type')), { [Op.like]: `%${query.toLowerCase()}%` }) ] }) } return this.getAll({}, queryObject.where) }, /** * includeInstanceApplication: */ getAll: async (pagination = {}, where = {}, { excludeApplications = null, includeInstanceApplication = false, includeDeviceGroup = false } = {}) => { // Pagination const limit = Math.min(parseInt(pagination.limit) || 100, 100) if (pagination.cursor) { const cursors = pagination.cursor.split(',') cursors[cursors.length - 1] = M.Device.decodeHashid(cursors[cursors.length - 1]) pagination.cursor = cursors.join(',') } // Filtering if (pagination.filters?.state) { // Unknown is the blank state where.state = pagination.filters.state === 'unknown' ? '' : pagination.filters.state } // Filtering if (pagination.filters?.mode) { // Unknown is the blank state where.mode = pagination.filters.mode === 'unknown' ? '' : pagination.filters.mode } if (pagination.filters?.lastseen) { // Must be mapped to lastSeenAt filter const lastseen = pagination.filters.lastseen // Needs to be kept in sync with frontend (frontend/src/services/device-status.js) // Thresholds are currently running <1.5, safe <3, error >3 // To-do: Move this logic entirely server side rather than calculating it in the frontend if (lastseen === 'never') { where.lastSeenAt = null } else if (lastseen === 'running') { where.lastSeenAt = {} where.lastSeenAt[Op.gt] = new Date(Date.now() - (1.5 * 60 * 1000)) } else if (lastseen === 'safe') { where.lastSeenAt = {} where.lastSeenAt[Op.lte] = new Date(Date.now() - (1.5 * 60 * 1000)) where.lastSeenAt[Op.gt] = new Date(Date.now() - (3 * 60 * 1000)) } else if (lastseen === 'error') { where.lastSeenAt = {} where.lastSeenAt[Op.lte] = new Date(Date.now() - (3 * 60 * 1000)) } else { console.warn('Unknown lastseen filter, silently ignoring', lastseen) } } // Sorting const order = [['id', 'ASC']] if (pagination.order && Object.keys(pagination.order).length) { for (const key in pagination.order) { if (key === 'application') { // Sort by Device->Instance->Application.name if (includeInstanceApplication) { order.unshift([M.Project, M.Application, 'name', pagination.order[key] || 'ASC']) // Order Device->Application.name } else { order.unshift([M.Application, 'name', pagination.order[key] || 'ASC']) } } else if (key === 'instance') { order.unshift([M.Project, 'name', pagination.order[key] || 'ASC']) } else if (key === 'state-priority') { order.unshift([literal(` CASE WHEN "Device"."state" IN ('error', 'crashed') THEN 1 WHEN "Device"."state" IN ('running', 'safe', 'protected', 'warning') THEN 2 WHEN "Device"."state" IS NULL OR "Device"."state" IN ('stopped', 'offline', 'unknown', '') THEN 3 END `), 'ASC']) } else { order.unshift([key, pagination.order[key] || 'ASC']) } } } // Extra models to include const filteringOnInstanceApplication = !!where.ApplicationId && includeInstanceApplication const projectInclude = { model: M.Project, attributes: ['id', 'name', 'links'], required: filteringOnInstanceApplication } // Naive filter on Devices->Application if (typeof where.ApplicationId === 'string') { where.ApplicationId = M.Application.decodeHashid(where.ApplicationId) } if (excludeApplications || includeInstanceApplication) { projectInclude.include = { model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] } if (filteringOnInstanceApplication) { projectInclude.include.where = { id: where.ApplicationId } } else if (excludeApplications) { projectInclude.include.where = { id: { [Op.and]: { [Op.not]: null, [Op.notIn]: excludeApplications } } } // projectInclude.include.required = true } if (includeInstanceApplication) { projectInclude.include.required = true } if (excludeApplications) { // This query will filter for devices that are either directly assigned // to the application, or assigned to an instance that is assigned to the application where[Op.or] = { [Op.and]: { ApplicationId: { [Op.is]: null }, [Op.or]: { ProjectId: { [Op.is]: null }, '$Project->Application.id$': { [Op.not]: null } } }, ApplicationId: { [Op.or]: { // [Op.not]: null, [Op.notIn]: excludeApplications } } } } delete where.ApplicationId } const includes = [ { model: M.Team, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId'] }, projectInclude, { model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'] }, { model: M.ProjectSnapshot, as: 'activeSnapshot', attributes: ['id', 'hashid', 'name'] }, { model: M.Application, attributes: ['hashid', 'id', 'name', 'links'] } ] if (includeDeviceGroup) { includes.push({ model: M.DeviceGroup, attributes: ['hashid', 'id', 'name', 'description', 'ApplicationId'] }) } const statusOnlyIncludes = projectInclude.include?.where ? [projectInclude] : [] const [rows, count] = await Promise.all([ this.findAll({ where: buildPaginationSearchClause(pagination, where, ['Device.name', 'Device.type'], {}, order), include: pagination.statusOnly ? statusOnlyIncludes : includes, order, limit: pagination.statusOnly ? null : limit }), this.count({ where, include: statusOnlyIncludes }) ]) let nextCursors = [] if (rows.length === limit && limit > 0) { const lastRow = rows[rows.length - 1] nextCursors = order.map((sortProps) => { // Model, key, dir let [model, key] = sortProps // Key, dir if (arguments.length === 2) { key = model model = null } if (key === 'id') { key = 'hashid' } if (model === M.Application) { return lastRow.Project[model.name][key] } if (model !== null) { return lastRow[model.name][key] } return lastRow[key] }) } return { meta: { next_cursor: nextCursors.length > 0 ? nextCursors.join(',') : undefined }, count, devices: rows } }, byTargetSnapshot: async (snapshotHashId) => { const snapshotId = M.ProjectSnapshot.decodeHashid(snapshotHashId) return this.findAll({ include: [ { model: M.Team, attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId'] }, { model: M.Project, attributes: ['id', 'name', 'links'] }, { model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'], where: { id: snapshotId } }, { model: M.ProjectSnapshot, as: 'activeSnapshot', attributes: ['id', 'hashid', 'name'] } ] }) }, getDeviceProjectId: async (id) => { if (typeof id === 'string') { id = M.Device.decodeHashid(id) } const device = await this.findOne({ where: { id }, attributes: [ 'ProjectId' ] }) if (device) { return device.ProjectId } }, getDeviceApplicationId: async (id) => { if (typeof id === 'string') { id = M.Device.decodeHashid(id) } const device = await this.findOne({ where: { id }, attributes: [ 'ApplicationId' ] }) if (device && device.ApplicationId) { return M.Application.encodeHashid(device.ApplicationId) } }, getOwnerTypeAndId: async (id) => { if (typeof id === 'string') { id = M.Device.decodeHashid(id) } const device = await this.findOne({ where: { id }, attributes: [ 'ProjectId', 'ApplicationId' ] }) if (device) { if (device.ProjectId) { return { ownerType: 'instance', ownerId: device.ProjectId } } else if (device.ApplicationId) { return { ownerType: 'application', ownerId: M.Application.encodeHashid(device.ApplicationId) } } } return null }, /** * Recalculate the `settingsHash` for all devices * @param {boolean} [all=false] If `false` (or omitted), only devices where `settingsHash` == `null` will be recalculated. If `true`, all devices are updated. */ recalculateSettingsHashes: async (all) => { const findOpts = { where: { settingsHash: null }, attributes: ['hashid', 'id', 'name', 'type', 'targetSnapshotId', 'settingsHash'] } if (all) { delete findOpts.where } const devices = await this.findAll(findOpts) if (devices && devices.length) { devices.forEach(async (device) => { await device.updateSettingsHash() await device.save() }) } }, countByState: async (states, team, applicationId, membership, isAdmin) => { let teamId = team.id if (typeof teamId === 'string') { teamId = M.Team.decodeHashid(teamId) if (!teamId || teamId.length === 0) { throw new Error('Invalid TeamId') } } if (typeof applicationId === 'string') { applicationId = M.Application.decodeHashid(applicationId) if (!applicationId || applicationId.length === 0) { throw new Error('Invalid ApplicationId') } } const statesMap = {} const findAll = await this.findAll({ include: [ { model: M.Application, attributes: ['hashid', 'id'] }, { model: M.Project, attributes: ['id'], include: { model: M.Application, attributes: ['hashid', 'id', 'name'] } } ], where: { ...(states.length > 0 ? { [Op.or]: states.map(state => ({ state, TeamId: teamId, ...(applicationId ? { ApplicationId: applicationId } : {}) })) } : { TeamId: teamId }), ...(applicationId ? { ApplicationId: applicationId } : {}) } }) const platformRbacEnabled = app.config.features.enabled('rbacApplication') await team.ensureTeamTypeExists() const teamRbacEnabled = team.getFeatureProperty('rbacApplication', false) const rbacEnabled = platformRbacEnabled && teamRbacEnabled findAll.forEach((device) => { const applicationId = device.Application?.hashid ?? device.Project?.Application?.hashid if (rbacEnabled && applicationId && !app.hasPermission(membership, 'device:read', { applicationId }) && !isAdmin) { // This device is not accessible to this user, do not include in states map return } const state = device.state statesMap[state] = (statesMap[state] || 0) + 1 }) return Object.entries(statesMap).map(([state, count]) => ({ state, count })) }, byTeamForSearch: async (teamId, query) => { const queryObject = { include: [ { model: M.Team, where: { id: teamId }, attributes: ['hashid', 'id', 'name', 'slug'] }, { model: M.Application, required: true, attributes: ['hashid', 'id', 'name'] } ], where: { [Op.and]: [ where( fn('lower', col('Device.name')), { [Op.like]: `%${query.toLowerCase()}%` } ) ] } } return this.findAll(queryObject) } } } } } function hashSettings (settings) { const hash = crypto.createHash('sha256') hash.update(JSON.stringify(settings)) return hash.digest('hex') }