UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

614 lines (563 loc) • 29.5 kB
/** * This module provides the handler for device status events - as well as APIs * for sending commands to devices. */ const SemVer = require('semver') const { v4: uuidv4 } = require('uuid') const noop = () => {} const DEFAULT_TIMEOUT = 10000 const { filterAccessibleMCPServerFeatures } = require('../services/expert.js') // declare command and response monitor types (and freeze them) const CommandMonitorTemplate = { resolve: () => Promise.resolve({}), reject: () => Promise.reject(new Error('Command rejected')), resolved: false, rejected: false, createdAt: 0, expiresAt: 0, command: '', deviceId: '', teamId: '', correlationData: '' } Object.freeze(CommandMonitorTemplate) const CommandMessageTemplate = { command: '', deviceId: '', teamId: '', correlationData: '', createdAt: 0, expiresAt: 0, payload: Object() // eslint-disable-line no-object-constructor } Object.freeze(CommandMessageTemplate) /** @typedef {typeof CommandMonitorTemplate} ResponseMonitor */ /** @typedef {typeof CommandMessageTemplate} CommandMessage */ /** * @typedef MCPServerDetails * @property {any} team - team * @property {any} application - application * @property {any} instance - instance * @property {any} instanceName - instanceName * @property {any} instanceType - instanceType * @property {any} mcpServer - mcpServer * @property {any} mcpServerName - mcpServerName * @property {any} mcpEndpoint - mcpEndpoint * @property {any} mcpServerNameUnique - mcpServerNameUnique * @property {any} mcpServerDescription - mcpServerDescription * @property {any} mcpServerTitle - mcpServerTitle * @property {any} mcpServerVersion - mcpServerVersion */ /** * DeviceCommsHandler * @class DeviceCommsHandler * @memberof forge.comms */ class DeviceCommsHandler { /** * New DeviceCommsHandler instance * @param {import('../forge').ForgeApplication} app Fastify app * @param {import('./commsClient').CommsClient} client Comms Client */ constructor (app, client) { this.app = app this.client = client this.deviceLogClients = {} this.deviceLogHeartbeats = {} this.deviceResourcesHeartbeats = {} /** @type {Object.<string, typeof CommandResponseMonitor>} */ this.inFlightCommands = {} this.deviceLogHeartbeatInterval = -1 this.deviceResourcesHeartbeatInterval = -1 // Listen for any incoming device status events client.on('status/device', (status) => { this.handleStatus(status) }) client.on('response/device', (response) => { this.handleCommandResponse(response) }) // Handle expert inflight requests sent from the FF Expert Agent - intended for an MCP server on a specific remote instance client.on('request/device/expert/insight', async (userId, command, /** @type {MCPServerDetails} */ mcpServer, mcpDefinitionKind, mcpDefinition, data, onSuccess, onError) => { // MCP ROUTE: step 2 (remote) // Called By: an MQTT inflight message (from the Expert Agent) // Calls To : device agent via the established command channel (sendCommandAwaitReply) const { team: teamId, application: applicationId, instance: instanceId, instanceType, mcpServer: mcpServerId } = mcpServer const isToolCall = command === 'mcp:call-tool' const isResourceCall = command === 'mcp:read-resource' && mcpDefinitionKind === 'mcp_resource' const isResourceTemplateCall = command === 'mcp:read-resource' && mcpDefinitionKind === 'mcp_resource_template' const toolDefinition = isToolCall ? mcpDefinition : null const resourceDefinition = isResourceCall ? mcpDefinition : null const resourceTemplateDefinition = isResourceTemplateCall ? mcpDefinition : null try { // Premise: // The incoming request contains information to call an MCP tool/resource on a specific instance. // 1. Check that the for the MCP server supplied, the user has access (application level) // 2. Check that the for the MCP server feature being performed (tool/resource/resource_template), that the user has access to (feature level) // 3. Re-resolve the MCP server against the team's trusted MCP registry, verify // instance/application ownership, and get/create access tokens as needed based on the // instance node security settings // first pass - basic sanity checks, picking up associated models for the user, team membership, etc if (!teamId || !applicationId || !instanceId || !mcpServerId || instanceType !== 'device') { return onError('Invalid MCP request - missing required fields', 'MCP_INVALID_REQUEST') } if (typeof instanceId !== 'string') { return onError('Invalid instance ID', 'MCP_INVALID_INSTANCE_ID') } const instance = await this.app.db.models.Device.byId(instanceId) if (!instance) { return onError('Invalid instance', 'MCP_INVALID_INSTANCE') } // get associated db models for the user and team membership // reload the trusted registration and ensure it is still valid for this team and instance const registration = await app.db.models.MCPRegistration.byId(mcpServerId) if (!registration) { return onError('No MCP registration found', 'MCP_NO_REGISTRATION') } const application = instance.Application await application.reload({ attributes: ['TeamId'] }) const team = instance.Team const teamOk = team.hashid === teamId && application.TeamId === team.id && registration.TeamId === team.id const applicationOk = application.hashid === applicationId && instance.ApplicationId === application.id const instanceOk = registration.targetId === instance.id.toString() && registration.targetType === instanceType if (!teamOk || !applicationOk || !instanceOk) { return onError('Invalid team, application, or instance', 'MCP_INVALID_TEAM_APPLICATION_INSTANCE') } const serverEntry = { application, server: { ...mcpServer, tools: toolDefinition ? [toolDefinition] : [], resources: resourceDefinition ? [resourceDefinition] : [], resourceTemplates: resourceTemplateDefinition ? [resourceTemplateDefinition] : [] } } const user = await app.db.models.User.byId(userId) if (!user || user.hashid !== userId) { return onError('Invalid user', 'MCP_INVALID_USER') } const existingRole = await user.getTeamMembership(teamId) const accessibleServers = filterAccessibleMCPServerFeatures(app, [serverEntry], team, existingRole) const accessibleServer = accessibleServers.find(s => s.mcpServer === mcpServerId) if (!accessibleServer) { return onError('User does not have access to MCP server', 'MCP_NO_ACCESS') } const commandData = { kind: mcpDefinitionKind, endpoint: null // updated below after checks and other data is appended } // Prepare command data based on the type of MCP call (tool, resource, or resource template) if (isToolCall) { const accessibleTool = accessibleServer.tools.find(t => t.name === data.name) if (!accessibleTool) { return onError('User does not have access to MCP tool', 'MCP_NO_ACCESS_TOOL') } commandData.name = data.name commandData.input = data.input } else if (isResourceCall) { const accessibleResource = accessibleServer.resources.find(r => r.uri === resourceDefinition.uri) if (!accessibleResource) { return onError('User does not have access to MCP resource', 'MCP_NO_ACCESS_RESOURCE') } commandData.uri = data.uri } else if (isResourceTemplateCall) { const accessibleResourceTemplate = accessibleServer.resourceTemplates.find(r => r.uriTemplate === resourceTemplateDefinition.uriTemplate) if (!accessibleResourceTemplate) { return onError('User does not have access to MCP resource template', 'MCP_NO_ACCESS_RESOURCE_TEMPLATE') } // Prepare the commandData for the resource template call, including resolving the final URI from the template and input values // NOTE: The Expert Agent will typically unfurl the template and provide a fully resolved URI, but if it is not provided (or contains // placeholders), we will compute it below using the template and input values commandData.uri = data.uri commandData.uriTemplate = data.uriTemplate commandData.input = data.input if ((!commandData.uri || /\{([^}]+)\}/.test(commandData.uri))) { // compute the final URI by replacing placeholders in the template with input values const template = data.uriTemplate || commandData.uri const input = data.input || {} commandData.uri = template.replace(/\{([^}]+)\}/g, (match, key) => { const cleanKey = key.replace(/[*?]/g, '') // strip RFC6570 modifiers e.g. {var*} or {var?} to get the clean key for input lookup return input[cleanKey] !== undefined ? encodeURIComponent(input[cleanKey]) : match }) } } else { return onError('Invalid MCP command', 'MCP_INVALID_COMMAND') } // update the endpoint with the resolved access token for the instance and team const teamType = await instance.Team.getTeamType() const teamHttpSecurityFeature = !!teamType.properties.features?.teamHttpSecurity commandData.endpoint = { mcpEndpoint: mcpServer.mcpEndpoint, headers: mcpServer.headers || {}, accessToken: await app.expert.mcp.getOrCreateToken(instance, mcpServer.instanceType, instanceId, teamHttpSecurityFeature) || null } try { const result = await this.sendCommandAwaitReply(teamId, instanceId, command, commandData, { timeout: 30000 }) onSuccess(result) } catch (err) { return onError(`An error occurred performing insight request: ${err.message}`, 'MCP_INSIGHT_REQUEST_ERROR', err) } } catch (err) { return onError(`Error handling expert insights inflight request: ${err.message}`, 'MCP_INSIGHT_REQUEST_ERROR', err) } }) client.on('logs/heartbeat', (beat) => { this.deviceLogHeartbeats[beat.id] = beat.timestamp }) client.on('logs/disconnect', (beat) => { const parts = beat.id.split(':') this.sendCommand(parts[0], parts[1], 'stopLog', '') this.app.log.info(`Disable device logging ${parts[1]} in team ${parts[0]}`) delete this.deviceLogHeartbeats[beat.id] }) this.deviceLogHeartbeatInterval = setInterval(() => { const now = Date.now() for (const [key, value] of Object.entries(this.deviceLogHeartbeats)) { if (now - value > 12500) { const parts = key.split(':') this.sendCommand(parts[0], parts[1], 'stopLog', '') this.app.log.info(`Disable device logging ${parts[1]} in team ${parts[0]}`) delete this.deviceLogHeartbeats[key] } } }, 15000) client.on('resources/heartbeat', (beat) => { this.deviceResourcesHeartbeats[beat.id] = beat.timestamp }) client.on('resources/disconnect', (beat) => { const parts = beat.id.split(':') this.sendCommand(parts[0], parts[1], 'stopResources', '') this.app.log.info(`Disable device logging ${parts[1]} in team ${parts[0]}`) delete this.deviceResourcesHeartbeats[beat.id] }) this.deviceResourcesHeartbeatInterval = setInterval(() => { const now = Date.now() for (const [key, value] of Object.entries(this.deviceResourcesHeartbeats)) { if (now - value > 12500) { const parts = key.split(':') this.sendCommand(parts[0], parts[1], 'stopResources', '') this.app.log.info(`Disable device resources ${parts[1]} in team ${parts[0]}`) delete this.deviceResourcesHeartbeats[key] } } }, 15000) } async handleStatus (status) { // Check it looks like a valid status message if (status.id && status.status) { const deviceId = status.id // Load a minimal device model without any associations. const device = await this.app.db.models.Device.byId(deviceId, { includeAssociations: false }) if (!device) { // TODO: log invalid device return } const teamId = this.app.db.models.Team.encodeHashid(device.TeamId) const startTime = Date.now() try { const previousState = device.state const payload = JSON.parse(status.status) await this.app.db.controllers.Device.updateState(device, payload) if (payload === null) { // This device is busy updating - don't interrupt it this.app.log.info({ msg: 'Device status update - null status', device: deviceId, team: teamId, responseTime: Date.now() - startTime }) return } const maskTransientStop = previousState === 'restarting' && payload.state === 'stopped' if (!maskTransientStop && payload.state !== previousState) { this.app.comms.team.notifyDeviceState(teamId, status.id, payload.state) } // If the status state===unknown, the device is waiting for confirmation // it has the right details. Always response with an 'update' command in // this scenario let sendUpdateCommand = payload.state === 'unknown' let sendUpdateReason = [] // If the device is owned by an application (in the DB) and the agent is reporting version < 1.11.0 // then we need to send an update command to the device if (Object.hasOwn(payload, 'snapshot') && device.isApplicationOwned) { if (!device.agentVersion || SemVer.lt(device.agentVersion, '1.11.0')) { sendUpdateCommand = true } } if (Object.hasOwn(payload, 'project') && payload.project !== (device.ProjectId || null)) { // The Project is incorrect sendUpdateReason.push('project') sendUpdateCommand = true } if (Object.hasOwn(payload, 'application')) { // The payload contains an application hashid - get the hashid of the expected application const expectedApplicationId = device.ApplicationId ? this.app.db.models.Application.encodeHashid(device.ApplicationId) : null if (payload.application !== expectedApplicationId) { // The Application is incorrect sendUpdateReason.push('application') sendUpdateCommand = true } } if (Object.hasOwn(payload, 'snapshot')) { // The payload contains a snapshot hashid - get the hashid of the expected snapshot let targetSnapshotId = device.targetSnapshotId if (targetSnapshotId) { targetSnapshotId = this.app.db.models.ProjectSnapshot.encodeHashid(targetSnapshotId) } const reportedSnapshotId = payload.snapshot === '0' ? null : payload.snapshot if (reportedSnapshotId !== (targetSnapshotId || null)) { sendUpdateReason.push('snapshot') sendUpdateCommand = true // The Snapshot reported in device status does not match the device model target snapshot } else if (reportedSnapshotId && !device.isApplicationOwned) { // load the full snapshot (as specified by the device status) from the db so we can check the snapshots // `ProjectId` is "something" (not orphaned) and matches the device's project const reportedSnapshot = (await this.app.db.models.ProjectSnapshot.byId(reportedSnapshotId, { includeFlows: false, includeSettings: false })) if (reportedSnapshot && payload.project !== (reportedSnapshot?.ProjectId || null)) { // The project the device is reporting it belongs to does not match the target Snapshot parent project sendUpdateReason.push('snapshot') sendUpdateCommand = true } } } if (Object.hasOwn(payload, 'settings') && payload.settings !== (device.settingsHash || null)) { // The Settings are incorrect sendUpdateReason.push('settings') sendUpdateCommand = true } if (sendUpdateCommand) { sendUpdateReason = sendUpdateReason.join(',') await this.app.db.controllers.Device.sendDeviceUpdateCommand(device) } else { sendUpdateReason = undefined } this.app.log.info({ msg: 'Device status update', device: deviceId, team: teamId, sendUpdateCommand, sendUpdateReason, responseTime: Date.now() - startTime }) } catch (err) { this.app.log.info({ msg: 'Device status update error', device: deviceId, team: teamId, responseTime: Date.now() - startTime, err: err.message }) // Not a JSON payload - ignore if (err instanceof SyntaxError) { return } throw err } } } /** * Handle a command response message from a device * Typically this will be a response to a command sent by the platform * @param {Object} response Reply from the device * @returns {Promise<void>} * @see sendCommandAwaitReply */ async handleCommandResponse (response) { // Check it looks like a valid response to a command // The response part should have the following: // * id: the device id // * message: the structured response (see below) // the message part should have the following: // * teamId: for message routing and verification // * deviceId: for message routing and verification // * command: // for command response verification // * correlationData: for correlating response with request // * payload: the actual response payload if (response.id && typeof response.message === 'string') { const message = JSON.parse(response.message) if (!message.command || !message.correlationData || !message.payload) { return // Not a valid response } const deviceId = response.id const device = await this.app.db.models.Device.byId(deviceId) if (!device) { return // Not a valid device } const inFlightCommand = this.inFlightCommands[message.correlationData] if (inFlightCommand) { // This command is known to the local instance - process it inFlightCommand.resolve(message.payload) delete this.inFlightCommands[message.correlationData] } } } /** * Send a command to all devices assigned to a project using the broadcast * topic. * @param {String} teamId * @param {String} projectId * @param {String} command * @param {Object} payload */ sendCommandToProjectDevices (teamId, projectId, command, payload) { const topic = `ff/v1/${teamId}/p/${projectId}/command` this.client.publish(topic, JSON.stringify({ command, ...payload })) } /** * Send a command to all devices assigned to an application using the broadcast * topic. * @param {String} teamId * @param {String} projectId * @param {String} command * @param {Object} payload */ sendCommandToApplicationDevices (teamId, applicationId, command, payload) { const topic = `ff/v1/${teamId}/a/${applicationId}/command` this.client.publish(topic, JSON.stringify({ command, ...payload })) } /** * Send a command to a specific device using its command topic. * @param {String} teamId * @param {String} deviceId * @param {String} command * @param {Object} payload * @param {import('mqtt').IClientPublishOptions} [options] * @param {import('mqtt').PacketCallback} [callback] */ sendCommand (teamId, deviceId, command, payload, options, callback) { if (typeof options === 'function') { callback = options options = {} } callback = callback || noop const topic = `ff/v1/${teamId}/d/${deviceId}/command` this.client.publish(topic, JSON.stringify({ command, ...payload }), options, callback) } async sendCommandAsync (teamId, deviceId, command, payload, options) { return new Promise((resolve, reject) => { this.sendCommand(teamId, deviceId, command, payload, options, (err, packet) => { if (err) { reject(err) } else { resolve() } }) }) } /** * Send a command to a specific device using its command topic and wait for a response. * The response will be received by [handleCommandResponse]{@link handleCommandResponse} * @param {String} teamId The team Id this device belongs to * @param {String} deviceId The device Id * @param {String} command The command to send to the device * @param {Object} payload The payload to send to the device * @param {Object} routingOptions Options * @param {Number} [routingOptions.timeout=DEFAULT_TIMEOUT] The timeout in milliseconds to wait for a response * @returns {Promise<Any>} The response payload * @see handleCommandResponse */ async sendCommandAwaitReply (teamId, deviceId, command, payload, options = { timeout: DEFAULT_TIMEOUT }) { // sanitise the options object options = options || {} options.timeout = (typeof options.timeout === 'number' && options.timeout > 0) ? options.timeout : DEFAULT_TIMEOUT const inFlightCommand = DeviceCommsHandler.newResponseMonitor(command, deviceId, teamId, this.client.platformId, options) const promise = new Promise((resolve, reject) => { inFlightCommand.resolve = (payload) => { inFlightCommand.resolved = true clearTimeout(inFlightCommand.timer) resolve(payload) delete this.inFlightCommands[inFlightCommand.correlationData] } inFlightCommand.reject = async (err) => { inFlightCommand.rejected = true clearTimeout(inFlightCommand.timer) reject(err) delete this.inFlightCommands[inFlightCommand.correlationData] } }) // create a promise with timeout inFlightCommand.timer = setTimeout(() => { if (inFlightCommand.resolved) return if (inFlightCommand.rejected) return inFlightCommand.reject(new Error('Command timed out')) }, options.timeout) this.inFlightCommands[inFlightCommand.correlationData] = inFlightCommand // Generate suitable MQTT options /** @type {import('mqtt').IClientPublishOptions} */ const mqttOptions = {} // add response topic, correlation data and user properties to the payload const commandData = DeviceCommsHandler.newCommandMessage(inFlightCommand, payload) // send command, return the promise and await response this.sendCommand(teamId, deviceId, command, commandData, mqttOptions) return promise } /** * Build a new command message object for sending to a device * @param {ResponseMonitor} cmr The `ResponseMonitor` object to build this new command message from * @param {Object} payload The payload to send to the device * @returns {CommandMessage} */ static newCommandMessage (cmr, payload) { // clone the CommandMessage type object /** @type {CommandMessage} */ const commandMessage = Object.assign({}, CommandMessageTemplate) commandMessage.command = cmr.command commandMessage.createdAt = cmr.createdAt commandMessage.expiresAt = cmr.expiresAt commandMessage.deviceId = cmr.deviceId commandMessage.teamId = cmr.teamId commandMessage.correlationData = cmr.correlationData commandMessage.responseTopic = `ff/v1/${cmr.teamId}/d/${cmr.deviceId}/response/${cmr.platformId}` commandMessage.payload = payload return commandMessage } /** * Build a new ResponseMonitor object for correlating with the response from a device * @param {String} command The command * @param {String} deviceId The device Id * @param {String} teamId The team Id * @param {Object} [options={ timeout: DEFAULT_TIMEOUT }] Options * @returns {ResponseMonitor} */ static newResponseMonitor (command, deviceId, teamId, platformId, options = { timeout: DEFAULT_TIMEOUT }) { const now = Date.now() const correlationData = uuidv4() // generate a random correlation data (uuid) /** @type {ResponseMonitor} */ const responseMonitor = Object.assign({}, CommandMonitorTemplate) responseMonitor.command = command responseMonitor.resolve = null responseMonitor.reject = null responseMonitor.resolved = false responseMonitor.rejected = false responseMonitor.createdAt = now responseMonitor.expiresAt = now + options?.timeout || DEFAULT_TIMEOUT responseMonitor.deviceId = deviceId responseMonitor.teamId = teamId responseMonitor.platformId = platformId responseMonitor.correlationData = correlationData return responseMonitor } /** * Enable the Node-RED editor on a device * @param {String} teamId Team id of the device * @param {String} deviceId Device id */ async enableEditor (teamId, deviceId, token) { // * Enable Device Editor (Step 5) - (forge->device:MQTT) send command "startEditor" and the token in the payload return await this.sendCommandAwaitReply(teamId, deviceId, 'startEditor', { token }) // returns true if successful } /** * Disable the Node-RED editor on a device * @param {String} teamId Team id of the device * @param {String} deviceId Device id */ async disableEditor (teamId, deviceId) { await this.sendCommandAsync(teamId, deviceId, 'stopEditor', '') } /** * Shutdown log heartbeat interval */ async stopLogWatcher () { for (const [key] of Object.entries(this.deviceLogHeartbeats)) { const parts = key.split(':') try { await this.sendCommandAsync(parts[0], parts[1], 'stopLog', '') this.app.log.info(`Disable device logging ${parts[1]}`) } catch (err) { // ignore as shutting down } } clearInterval(this.deviceLogHeartbeatInterval) clearInterval(this.deviceResourcesHeartbeatInterval) } } module.exports = { DeviceCommsHandler: (app, client) => new DeviceCommsHandler(app, client) }