@flowfuse/device-agent
Version:
An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform
375 lines (353 loc) • 17.4 kB
JavaScript
const { info, warn, debug } = require('../logging/log')
const WebSocket = require('ws')
const { default: got } = require('got')
const { getWSProxyAgent } = require('../utils')
function newWsConnection (url, /** @type {WebSocket.ClientOptions} */ options) {
if (options) {
return new WebSocket(url, options)
}
return new WebSocket(url)
}
class EditorTunnel {
constructor (config, options) {
// this.client = new WebSocketClientOLD()
/** @type {Object.<string, WebSocket.client>} */
// this.client = new WebSocket()
/** @type {Object.<string, WebSocket>} */
this.wsClients = {}
this.deviceId = config.deviceId
this.port = config.port
this.config = config
this.options = options || {}
this.affinity = this.options.affinity
// How long to wait before attempting to reconnect. Start at 500ms - back
// off if connect fails
this.reconnectDelay = 1500
const forgeURL = new URL(config.forgeURL)
forgeURL.protocol = forgeURL.protocol === 'http:' ? 'ws:' : 'wss:'
this.url = forgeURL.toString()
this.localProtocol = config.https ? 'https' : 'http'
this.localWSProtocol = config.https ? 'wss' : 'ws'
}
/**
* Create a tunnel instance
* @param {Object} config tunnel configuration
* @param {string} config.deviceId device id
* @param {string} config.token device token
* @param {string} config.forgeURL forge URL
* @param {number} config.port port to tunnel to
* @returns {EditorTunnel} tunnel instance
*/
static create (config, options) {
return new EditorTunnel(config, options)
}
async connect () {
const thisTunnel = this
let unexpectedPacketMonitor = 0
if (this.socket) {
this.close()
}
const forgeWSEndpoint = `${this.url}api/v1/devices/${this.deviceId}/editor/comms/${this.options.token}`
info(`Connecting editor tunnel to ${forgeWSEndpoint}`)
// * Enable Device Editor (Step 8) - (device->forge:WS) Initiate WS connection (with token)
const headers = {
'x-access-token': this.options.token
}
if (this.affinity) {
headers.cookie = `FFSESSION=${this.affinity}`
}
/** @type {WebSocket.ClientOptions} */
const socketOptions = {
headers,
agent: getWSProxyAgent(forgeWSEndpoint)
}
const socket = newWsConnection(forgeWSEndpoint, socketOptions)
socket.on('upgrade', (evt) => {
if (evt.headers && evt.headers['set-cookie']) {
let cookies = evt.headers['set-cookie']
if (!Array.isArray(cookies)) {
cookies = [cookies]
}
cookies.forEach(cookie => {
const parts = cookie.split(';')[0].split(['='])
if (parts[0] === 'FFSESSION') {
this.affinity = parts[1]
}
})
}
})
socket.onopen = (evt) => {
unexpectedPacketMonitor = 0
info('Editor tunnel connected')
// Reset reconnectDelay
this.reconnectDelay = 1500
this.socket.on('message', async (message) => {
// a message coming over the tunnel from a remote editor
const request = JSON.parse(message.toString('utf-8'))
if (request.ws) {
// A websocket related event
if (request.id !== undefined && request.url) {
// An editor has created a new comms connection.
// Create a corresponding local connection to the
// local runtime
const localWSEndpoint = `${thisTunnel.localWSProtocol}://127.0.0.1:${thisTunnel.port}/device-editor${request.url}`
debug(`[${request.id}] Connecting local comms to ${localWSEndpoint}`)
const tunnelledWSClient = newWsConnection(localWSEndpoint, { rejectUnauthorized: false })
thisTunnel.wsClients[request.id] = tunnelledWSClient
tunnelledWSClient._messageQueue = []
tunnelledWSClient.sendOrQueue = function (payload) {
if (this.readyState !== WebSocket.OPEN) {
this._messageQueue.push(payload)
} else {
this.send(payload)
}
}
tunnelledWSClient._id = request.id // for debugging and tracking
tunnelledWSClient.on('open', () => {
debug(`[${request.id}] Local comms connected`)
tunnelledWSClient.on('message', (data) => {
// The runtime is sending a message to an editor
const sendData = {
id: request.id,
ws: true,
body: data.toString('utf-8')
}
// console.log(`[${request.id}] R>E`, sendData.body)
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(sendData))
}
})
// Now the local comms is connected, send anything
// that had got queued up whilst we were getting
// connected
while (tunnelledWSClient._messageQueue.length > 0) {
tunnelledWSClient.send(tunnelledWSClient._messageQueue.shift())
}
})
tunnelledWSClient.on('close', (code, reason) => {
debug(`[${request.id}] Local comms connection closed code=${code} reason=${reason}`)
// WS to local node-red has closed. Send a notification
// to the platform so it can close the proxied editor
// websocket to match
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
id: request.id,
ws: true,
closed: true
}))
}
thisTunnel.wsClients[request.id]?.removeAllListeners()
thisTunnel.wsClients[request.id] = null
})
tunnelledWSClient.on('error', (err) => {
warn(`[${request.id}] Local comms connection error`)
warn(err)
thisTunnel.wsClients[request.id]?.close(1006, err.message)
thisTunnel.wsClients[request.id] = null
})
} else if (thisTunnel.wsClients[request.id]) {
// A message relating to an existing comms connection
if (request.closed) {
// An editor has closed its websocket - so we should
// close the corresponding local connection
debug(`[${request.id}] Closing local comms connection`)
thisTunnel.wsClients[request.id].close()
} else {
// An editor has sent a message over the websocket
// - forward over the local connection
// console.log(`[${request.id}] E>R`, request.body)
const wsClient = thisTunnel.wsClients[request.id]
let body = request.body
if (/\/comms$/.test(wsClient.url)) {
if (/^{"auth":/.test(body)) {
// This is the comms auth packet. Substitute the active
// access token
body = `{"auth":"${this.options.token}"}`
}
}
wsClient.sendOrQueue(body)
}
} else {
if (unexpectedPacketMonitor > 1) {
unexpectedPacketMonitor = 0
warn(`[${request.id}] Unexpected editor comms packet ${JSON.stringify(request, null, 4)}`)
this.close(1006, 'Non-connect packet received for unknown connection id') // 1006 = Abnormal closure
} else {
unexpectedPacketMonitor++
}
}
} else {
// An http related event
const reqHeaders = { ...request.headers }
// add bearer token to the request headers
if (thisTunnel.options.token) {
reqHeaders['x-access-token'] = thisTunnel.options.token
}
// make request to the local device
// add leading slash (if missing)
const url = request.url.startsWith('/') ? request.url : `/${request.url || ''}`
const fullUrl = `${thisTunnel.localProtocol}://127.0.0.1:${thisTunnel.port}/device-editor${url}`
// ↓ useful for debugging but very noisy
// console.log('Making a request to:', fullUrl, 'x-access-token:', request.method, reqHeaders['x-access-token'])
// debug(`proxy [${request.method}] ${fullUrl}`)
// debug(`body ${request.body}`)
let body = request.body
if (body && body[0] === '"') {
try {
body = JSON.parse(body)
} catch (err) {
// ignore
}
}
const options = {
headers: reqHeaders,
method: request.method,
body,
throwErrors: false
}
if (thisTunnel.localProtocol === 'https') {
options.https = { rejectUnauthorized: false }
}
got(fullUrl, options).then(response => {
// debug(`proxy [${request.method}] ${fullUrl} : sending response: status ${response.statusCode}`)
// send response back to the forge
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
id: request.id,
headers: response.headers,
body: response.rawBody,
status: response.statusCode
}))
}
}).catch(_err => {
// debug(`proxy [${request.method}] ${fullUrl} : error ${_err.toString()}`)
// ↓ useful for debugging but noisy due to .map files
// console.log(fullUrl, options, _err)
// console.log(JSON.stringify(request))
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
id: request.id,
body: undefined,
status: 404
}))
}
})
}
})
clearInterval(this.pingInterval)
this.pingInterval = setInterval(() => {
debug('Sending WS editor tunnel ping')
try {
socket.ping()
} catch (err) { }
}, 40000)
}
socket.on('close', async (code, reason) => {
if (Buffer.isBuffer(reason)) {
reason = reason.toString()
}
// The socket connection to the platform has closed
info(`Editor tunnel closed code=${code} reason=${reason}`)
socket.removeAllListeners()
this.socket = null
clearInterval(this.pingInterval)
clearTimeout(this.reconnectTimeout)
// Pre 1.12, FF returned '1008/No Tunnel' - but 1008 was also used
// for other scenarios, so we have to check both code and reason text.
// 1.12+ returns '4004/No Tunnel' - where 4004 is only used for this
// scenario so we don't have to worry about the text (which could change in the future).
if ((code === 1008 && reason !== 'No tunnel') || code !== 4004) {
// Assume we need to be reconnecting. If this close is due
// to a request from the platform to turn off editor access,
// .close will get called
this.closeLocalWebSockets()
this.reconnect()
} else {
// A 4004/'No tunnel' response means the platform doesn't think
// we should be connecting - so no point retrying
this.close(code, reason)
}
})
socket.on('error', (err) => {
if (err.code === 'ECONNREFUSED') {
warn('Editor tunnel connection refused')
} else {
warn(`Editor tunnel error: ${err}`)
console.warn('socket.error', err)
this.close(1006, err.message) // 1006 = Abnormal Closure
}
})
this.socket = socket
return !!(await this.waitForConnection())
}
close (code, reason) {
code = code || 1000
reason = reason || 'Normal Closure'
this.closeLocalWebSockets()
// close the socket
if (this.socket) {
try {
// catch and (effectively) ignore errors (don't crash on "WebSocket was closed before the connection was established")
this.socket.close()
} catch (_err) {
debug(`Error closing socket: ${_err.message}`)
}
// Remove the event listeners so we don't trigger the reconnect
// handling
this.socket.removeAllListeners()
info('Editor tunnel closed')
}
this.socket = null
// ensure any active timers are stopped
clearInterval(this.connectionReadyInterval)
clearTimeout(this.reconnectTimeout)
clearInterval(this.pingInterval)
}
async waitForConnection () {
return new Promise((resolve, reject) => {
const startTime = Date.now()
clearInterval(this.connectionReadyInterval)
// Poll every 2 seconds, but timeout after 10
this.connectionReadyInterval = setInterval(() => {
if (this.socket) {
if (this.socket.readyState === WebSocket.OPEN) {
clearInterval(this.connectionReadyInterval)
resolve(true)
} else if (this.socket.readyState !== WebSocket.CONNECTING || Date.now() - startTime > 10000) {
// Stop polling if readyState is CLOSING/CLOSED, or we've been
// trying to connect to over 10s
if (this.socket.readyState === WebSocket.CONNECTING) {
// Timed out - close the socket
try {
this.socket.close()
} catch (err) {
}
}
clearInterval(this.connectionReadyInterval)
resolve(false)
}
} else {
clearInterval(this.connectionReadyInterval)
resolve(false)
}
}, 2000)
})
}
closeLocalWebSockets () {
// Close all ws clients to the local Node-RED
Object.keys(this.wsClients || {}).forEach(c => {
this.wsClients[c]?.close()
delete this.wsClients[c]
})
}
reconnect () {
warn(`Editor tunnel reconnecting in ${(this.reconnectDelay / 1000).toFixed(1)}s`)
const reconnectDelay = this.reconnectDelay
// Bump the delay for next time... 500ms, 1.5s, 4.5s, 10s 10s 10s...
this.reconnectDelay = Math.min(this.reconnectDelay * 3, 10000)
this.reconnectTimeout = setTimeout(() => {
this.connect()
}, reconnectDelay)
}
}
module.exports = EditorTunnel