diff --git a/lib/agent.js b/lib/agent.js index 745259f..0d6e391 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -31,6 +31,7 @@ class Agent { // Track the local state of the agent. Start in 'unknown' state so // that the first MQTT check-in will trigger a response this.currentState = 'unknown' + this.editorToken = null // ensure licensed property is present (default to null) if (utils.hasProperty(this.config, 'licensed') === false) { this.config.licensed = null @@ -50,6 +51,7 @@ class Agent { } this.currentProject = null this.currentApplication = null + this.editorToken = null } else { // New format this.currentApplication = config.project ? null : (config.application || null) @@ -58,6 +60,7 @@ class Agent { this.currentSettings = config.settings || null this.currentMode = config.mode || 'autonomous' this.config.licensed = config.licensed || null + this.editorToken = config.editorToken || null } this.printAgentStatus() } catch (err) { @@ -103,7 +106,8 @@ class Agent { snapshot: this.currentSnapshot, settings: this.currentSettings, mode: this.currentMode, - licensed: this.config.licensed + licensed: this.config.licensed, + editorToken: this.editorToken })) } @@ -249,6 +253,7 @@ class Agent { await this.saveProject() } else { // exiting developer mode + this.editorToken = null // clear the discarded token let _launcher = this.launcher if (!_launcher) { // create a temporary launcher to read the current snapshot on disk @@ -345,7 +350,7 @@ class Agent { } /** A flag to inhibit updates if we are in developer mode */ - const inhibitUpdates = this.currentMode === 'developer' + const developerMode = this.currentMode === 'developer' /** A flag to indicate execution should skip to the update step */ const skipToUpdate = newState?.reloadSnapshot === true @@ -353,18 +358,19 @@ class Agent { if (newState === null) { // The agent should not be running (bad credentials/device details) // Wipe the local configuration - if (inhibitUpdates === false) { + if (developerMode === false) { this.stop() this.currentSnapshot = null this.currentApplication = null this.currentProject = null this.currentSettings = null this.currentMode = null + this.editorToken = null await this.saveProject() this.currentState = 'stopped' this.updating = false } - } else if (!skipToUpdate && inhibitUpdates === false && newState.application === null && this.currentOwnerType === 'application') { + } else if (!skipToUpdate && developerMode === false && newState.application === null && this.currentOwnerType === 'application') { if (this.currentApplication) { debug('Removed from application') } @@ -392,7 +398,7 @@ class Agent { await this.saveProject() this.currentState = 'stopped' this.updating = false - } else if (!skipToUpdate && inhibitUpdates === false && newState.project === null && this.currentOwnerType === 'project') { + } else if (!skipToUpdate && developerMode === false && newState.project === null && this.currentOwnerType === 'project') { if (this.currentProject) { debug('Removed from project') } @@ -420,7 +426,7 @@ class Agent { await this.saveProject() this.currentState = 'stopped' this.updating = false - } else if (!skipToUpdate && inhibitUpdates === false && newState.snapshot === null) { + } else if (!skipToUpdate && developerMode === false && newState.snapshot === null) { // Snapshot removed, but project/application still set if (this.currentSnapshot) { debug('Active snapshot removed') @@ -472,13 +478,13 @@ class Agent { const snapShotUpdatePending = !!(!this.currentSnapshot && newState.snapshot) const projectUpdatePending = !!(newState.ownerType === 'project' && !this.currentProject && newState.project) const applicationUpdatePending = !!(newState.ownerType === 'application' && !this.currentApplication && newState.application) - if (unknownOrStopped && inhibitUpdates && snapShotUpdatePending && (projectUpdatePending || applicationUpdatePending)) { + if (unknownOrStopped && developerMode && snapShotUpdatePending && (projectUpdatePending || applicationUpdatePending)) { info('Developer Mode: no flows found - updating to latest snapshot') this.currentProject = newState.project this.currentApplication = newState.application updateSnapshot = true updateSettings = true - } else if (inhibitUpdates === false) { + } else if (developerMode === false) { if (utils.hasProperty(newState, 'project') && (!this.currentSnapshot || newState.project !== this.currentProject)) { info('New instance assigned') this.currentApplication = null @@ -528,6 +534,9 @@ class Agent { if (this.mqttClient) { this.mqttClient.setProject(this.currentProject) this.mqttClient.setApplication(this.currentApplication) + if (developerMode && this.editorToken) { + this.mqttClient.startTunnel(this.editorToken) + } } this.checkIn(2) this.currentState = 'stopped' @@ -573,6 +582,9 @@ class Agent { if (this.mqttClient) { this.mqttClient.setProject(this.currentProject) this.mqttClient.setApplication(this.currentApplication) + if (developerMode && this.editorToken) { + this.mqttClient.startTunnel(this.editorToken) + } } this.checkIn(2) } catch (err) { @@ -676,6 +688,14 @@ class Agent { } }) } + + async saveEditorToken (token) { + const changed = this.editorToken !== token + this.editorToken = token + if (changed) { + await this.saveProject() + } + } } module.exports = { diff --git a/lib/logging/log.js b/lib/logging/log.js index 1ff3892..92d4cda 100644 --- a/lib/logging/log.js +++ b/lib/logging/log.js @@ -24,6 +24,10 @@ function NRlog (msg) { } catch (eee) { jsMsg = { ts: Date.now(), level: '', msg } } + if (!Object.hasOwn(jsMsg, 'ts') && !Object.hasOwn(jsMsg, 'level')) { + // not a NR log message + jsMsg = { ts: Date.now(), level: '', msg } + } const date = new Date(jsMsg.ts) if (typeof jsMsg.msg !== 'string') { jsMsg.msg = JSON.stringify(jsMsg.msg) diff --git a/lib/mqtt.js b/lib/mqtt.js index 5915fd6..46119c6 100644 --- a/lib/mqtt.js +++ b/lib/mqtt.js @@ -100,23 +100,7 @@ class MQTTClient { this.logEnabled = false return } else if (msg.command === 'startEditor') { - info('Enabling remote editor access') - if (this.tunnel) { - this.tunnel.close() - this.tunnel = null - } - if (!this.agent.launcher) { - info('No running Node-RED instance, not starting editor') - this.sendCommandResponse(msg, { connected: false, token: msg?.payload?.token, error: 'noNRRunning' }) - return - } - // * Enable Device Editor (Step 6) - (forge:MQTT->device) Create the tunnel on the device - this.tunnel = EditorTunnel.create(this.config, { token: msg?.payload?.token }) - // * Enable Device Editor (Step 7) - (device) Begin the device tunnel connect process - const result = await this.tunnel.connect() - // * Enable Device Editor (Step 10) - (device->forge:MQTT) Send a response to the platform - this.sendCommandResponse(msg, { connected: result, token: msg?.payload?.token }) - this.sendStatus() + await this.startTunnel(msg.payload?.token, msg) return } else if (msg.command === 'stopEditor') { if (this.tunnel) { @@ -281,6 +265,47 @@ class MQTTClient { } }) } + + async startTunnel (token, msg) { + info('Enabling remote editor access') + try { + if (this.tunnel) { + this.tunnel.close() + this.tunnel = null + } + if (!this.agent.launcher) { + info('No running Node-RED instance, not starting editor') + if (msg) { + this.sendCommandResponse(msg, { connected: false, token, error: 'noNRRunning' }) + } + return + } + + // * Enable Device Editor (Step 6) - (forge:MQTT->device) Create the tunnel on the device + this.tunnel = EditorTunnel.create(this.config, { token }) + + // * Enable Device Editor (Step 7) - (device) Begin the device tunnel connect process + const result = await this.tunnel.connect() + + // store the token for later use (i.e. device agent is restarted) + await this.saveEditorToken(result ? token : null) + + if (msg) { + // * Enable Device Editor (Step 10) - (device->forge:MQTT) Send a response to the platform + this.sendCommandResponse(msg, { connected: result, token }) + } + } catch (err) { + warn(`Error starting editor tunnel: ${err}`) + if (msg) { + this.sendCommandResponse(msg, { connected: false, token, error: err.toString() }) + } + } + this.sendStatus() + } + + async saveEditorToken (token) { + await this.agent?.saveEditorToken(token) + } } module.exports = { diff --git a/test/unit/lib/agent_spec.js b/test/unit/lib/agent_spec.js index cbca497..c6e328d 100644 --- a/test/unit/lib/agent_spec.js +++ b/test/unit/lib/agent_spec.js @@ -50,7 +50,7 @@ describe('Agent', function () { } async function writeConfig ( agent, - { application, project, snapshot, settings, mode, licensed } = { application: null, project: null, snapshot: null, settings: null, mode: null, licensed: null } + { application, project, snapshot, settings, mode, licensed, editorToken } = { application: null, project: null, snapshot: null, settings: null, mode: null, licensed: null, editorToken: null } ) { project = project || agent?.currentProject || null application = application || agent?.currentApplication || null @@ -69,7 +69,8 @@ describe('Agent', function () { application: application || null, ownerType, mode, - licensed + licensed, + editorToken })) } @@ -162,7 +163,9 @@ describe('Agent', function () { stop: sinon.stub(), setProject: sinon.stub(), setApplication: sinon.stub(), - checkIn: sinon.stub() + checkIn: sinon.stub(), + startTunnel: sinon.stub(), + sendCommandResponse: sinon.stub() }) sinon.stub(Launcher, 'newLauncher').callsFake((config, application, project, snapshot, settings, mode) => { return { @@ -248,6 +251,19 @@ describe('Agent', function () { agent.should.have.property('currentSnapshot') agent.currentSnapshot.should.have.property('id', 'snapshotId') }) + it('loads project file with a tunnel token', async function () { + const agent = createHTTPAgent() + await writeConfig(agent, { project: 'projectId', snapshot: 'snapshotId', settings: 'settingsId', mode: 'developer', editorToken: 'token' }) + + await agent.loadProject() + agent.should.have.property('currentMode', 'developer') + agent.should.have.property('editorToken', 'token') + agent.should.have.property('currentProject', 'projectId') + agent.should.have.property('currentSettings') + agent.currentSettings.should.have.property('hash', 'settingsId') + agent.should.have.property('currentSnapshot') + agent.currentSnapshot.should.have.property('id', 'snapshotId') + }) }) describe('saveProject', function () { @@ -283,6 +299,7 @@ describe('Agent', function () { agent.currentSettings = { hash: 'settingsId' } agent.currentSnapshot = { id: 'snapshotId' } agent.currentMode = 'developer' + agent.editorToken = 'token' await agent.saveProject() existsSync(agent.projectFilePath).should.be.true() await agent.loadProject() @@ -292,6 +309,7 @@ describe('Agent', function () { agent.should.have.property('currentSnapshot') agent.currentSnapshot.should.have.property('id', 'snapshotId') agent.should.have.property('currentMode', 'developer') + agent.should.have.property('editorToken', 'token') }) }) @@ -827,6 +845,30 @@ describe('Agent', function () { // test that checkIn was called with arg 'developer' agent.mqttClient.checkIn.called.should.be.true('checkIn was not called following switch to developer mode') }) + it('Clears editorToken when switching off developer mode', async function () { + const agent = createMQTTAgent() + agent.currentProject = 'projectId' + agent.currentApplication = null + agent.currentSnapshot = { id: 'snapshotId' } + agent.currentSettings = { hash: 'settingsId' } + agent.currentMode = 'developer' + agent.editorToken = 'test-token' + + const testLauncher = Launcher.newLauncher() + agent.launcher = testLauncher + await agent.start() + await agent.setState({ + mode: 'autonomous' + }) + for (let i = 0; i < 30; i++) { + if (agent.mqttClient.checkIn.called) { + break + } + await new Promise(resolve => setTimeout(resolve, 10)) + } + // test that checkIn was called with arg 'developer' + should(agent.editorToken).be.null() + }) it('reloads latest snapshot from platform when switching off developer mode (if snapshot ID changed)', async function () { sinon.stub(utils, 'compareNodeRedData').returns(false) const flows = [{ id: 'a-node-id', payload: 'i-am-original' }, {}] @@ -1036,5 +1078,79 @@ describe('Agent', function () { state.should.have.property('state', 'unknown') state.should.have.property('mode', 'developer') }) + describe('editor tunnel', function () { + it('reconnects tunnel in developer mode when editorToken is set', async function () { + // Create a temporary agent and set project, snapshot and settings IDs etc + // so that the agent will have the necessary properties to "start" the launcher + // and thus attempt to re-open the tunnel + // This is purely to generate config file with the necessary properties set + const agent = createMQTTAgent() + agent.currentMode = 'developer' + agent.editorToken = 'token' + agent.project = 'projectId' + agent.snapshot = 'snapshotId' + agent.settings = 'settingsId' + agent.currentSnapshot = { id: 'snapshotId' } + await agent.saveProject() + + // Create a new agent and load the project file created above + const agent2 = createMQTTAgent() + await agent2.loadProject() + await agent2.start() + + // fake a check-in what will start the launcher and the attempt to start the tunnel + const state = agent2.getState() + await agent2.setState(state) + + // fail safe this test by checking the launcher was created and start was called + // also, that the mqtt client was created and start was called + agent2.should.have.property('launcher').and.be.an.Object() + agent2.should.have.property('mqttClient').and.be.an.Object() + agent2.launcher.start.callCount.should.equal(1) + agent2.mqttClient.start.callCount.should.equal(1) + + // check that the tunnel is started with the correct token (arg0) + // NOTE: since this is not an MQTT commanded execution, 2nd arg should NOT be present + // and therefore sendCommandResponse should NOT be called + agent2.mqttClient.startTunnel.callCount.should.equal(1) + agent2.mqttClient.startTunnel.firstCall.calledWith('token').should.be.true() + should.not.exist(agent2.mqttClient.startTunnel.firstCall.args[1]) + agent2.mqttClient.sendCommandResponse.callCount.should.equal(0) // since this was not an MQTT commanded execution, no response should be sent + }) + it('does not attempt to reconnect tunnel if not in developer mode (even if editorToken is set)', async function () { + // Create a temporary agent and set project, snapshot and settings IDs etc + // so that the agent will have the necessary properties to "start" the launcher + // and thus attempt to re-open the tunnel (but only in developer mode) + // This is purely to generate config file with the necessary properties set + const agent = createMQTTAgent() + agent.currentMode = 'autonomous' // not developer mode this time + agent.editorToken = 'token' + agent.project = 'projectId' + agent.snapshot = 'snapshotId' + agent.settings = 'settingsId' + agent.currentSnapshot = { id: 'snapshotId' } + await agent.saveProject() + + // Create a new agent and load the project file created above + const agent2 = createMQTTAgent() + await agent2.loadProject() + await agent2.start() + + // fake a check-in what will start the launcher + const state = agent2.getState() + await agent2.setState(state) + + // fail safe this test by checking the launcher was created and start was called + // also, that the mqtt client was created and start was called + agent2.should.have.property('launcher').and.be.an.Object() + agent2.should.have.property('mqttClient').and.be.an.Object() + agent2.launcher.start.callCount.should.equal(1) + agent2.mqttClient.start.callCount.should.equal(1) + + // now ensure that the tunnel was NOT started + agent2.mqttClient.startTunnel.callCount.should.equal(0) + agent2.mqttClient.sendCommandResponse.callCount.should.equal(0) + }) + }) }) }) diff --git a/test/unit/lib/mqtt_spec.js b/test/unit/lib/mqtt_spec.js index 984b1e1..2aaa0f5 100644 --- a/test/unit/lib/mqtt_spec.js +++ b/test/unit/lib/mqtt_spec.js @@ -28,7 +28,8 @@ function createAgent (opts) { opts.state = opts.state || 'stopped' const newAgent = function () { this.updating = false - this.currentMode = opts.currentMode + this.currentMode = opts.currentMode || null + this.editorToken = opts.editorToken this.currentSnapshot = opts.currentSnapshot this.currentProject = opts.currentProject this.currentSettings = opts.currentSettings @@ -39,6 +40,7 @@ function createAgent (opts) { const agent = this return { currentMode: agent.currentMode, + editorToken: agent.editorToken, currentSnapshot: agent.currentSnapshot, currentProject: agent.currentProject, currentSettings: agent.currentSettings, @@ -48,7 +50,8 @@ function createAgent (opts) { }), getCurrentFlows: sinon.fake.returns(agent.flows), getCurrentCredentials: sinon.fake.returns(agent.credentials), - getCurrentPackage: sinon.fake.returns(agent.package) + getCurrentPackage: sinon.fake.returns(agent.package), + saveEditorToken: sinon.fake() } } return newAgent() @@ -60,7 +63,7 @@ describe('MQTT Comms', function () { /** @type {import('aedes-server-factory').Server} MQTT WS */ let httpServer /** @type {Aedes} MQTT Broker */ let aedes = null /** @type {MQTT.MqttClient} MQTT Client */ let mqtt - /** @type {MQTTClientComms} Agent mqttClient comms */ let mqttClient = null + /** @type {import('../../../lib/mqtt').MQTTClient} Agent mqttClient comms */ let mqttClient = null let currentId = 0 // incrementing id for each agent const sockets = {} // Maintain a hash of all connected sockets (for closing them later) @@ -73,9 +76,11 @@ describe('MQTT Comms', function () { const snapshot = opts.snapshotId !== null ? { id: opts.snapshotId } : null const settings = opts.settingsId !== null ? { hash: opts.settingsId } : null const mode = opts.mode || 'developer' + const editorToken = opts.editorToken || null const agent = createAgent({ currentMode: mode, + editorToken, currentProject: project, currentSettings: settings, currentSnapshot: snapshot, @@ -88,7 +93,7 @@ describe('MQTT Comms', function () { return new MQTTClientComms(agent, { dir: configDir, forgeURL: 'http://localhost:9000', - brokerURL: 'ws://localhost:9001', + brokerURL: 'ws://localhost:9800', brokerUsername: `device:${team}:${device}`, brokerPassword: 'pass' }) @@ -96,7 +101,7 @@ describe('MQTT Comms', function () { before(async function () { aedes = new Aedes() - const port = 9001 + const port = 9800 httpServer = createServer(aedes, { ws: true }) httpServer.listen(port, function () { console.log('websocket server listening on port ', port) @@ -264,6 +269,36 @@ describe('MQTT Comms', function () { response.payload.should.have.a.property('connected', false) response.payload.should.have.a.property('token', 'token-test') }) + it('Calls save token when commanded to startEditor', async function () { + mqttClient.start() + const commandTopic = `ff/v1/${mqttClient.teamId}/d/${mqttClient.deviceId}/command` + const responseTopic = `ff/v1/${mqttClient.teamId}/d/${mqttClient.deviceId}/response` + console.log('commandTopic', commandTopic) + console.log('responseTopic', responseTopic) + mqttClient.should.have.a.property('client').and.be.an.Object() + mqttClient.should.have.a.property('commandTopic').and.be.a.String().and.equal(commandTopic) + mqttClient.should.have.a.property('responseTopic').and.be.a.String().and.equal(responseTopic) + + mqttClient.agent.launcher = {} // fake a launcher so that `startTunnel` gets to the point where it saves the token + + const payload = { + command: 'startEditor', + correlationData: 'correlationData-test', + responseTopic, + payload: { + token: 'token-test' + } + } + const payloadStr = JSON.stringify(payload) + + // short delay to allow mqtt to connect and stack to unwind + await new Promise(resolve => setTimeout(resolve, 500)) + const response = await mqttPubAndAwait(commandTopic, payloadStr, responseTopic) + await new Promise(resolve => setTimeout(resolve, 50)) + response.should.have.a.property('command', 'startEditor') + mqttClient.agent.saveEditorToken.callCount.should.equal(1) + mqttClient.agent.saveEditorToken.firstCall.calledWith('token-test').should.be.true() + }) it('does not crash when agent.setState() throws', function (done) { // spy on warn() sinon.spy(console, 'log')