@flowfuse/device-agent
Version:
An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform
594 lines (548 loc) • 23.4 kB
JavaScript
// AgentManager: A class that manages the device agent.
// It can start/stop and reload the agent
const { initLogger, info, warn, debug } = require('./logging/log')
const { default: GOT } = require('got/dist/source')
const agent = require('./agent')
const os = require('os')
const fs = require('fs')
const path = require('path')
const yaml = require('yaml')
const { getHTTPProxyAgent } = require('./utils.js')
class AgentManager {
static options = null
constructor () {
if (AgentManager.options) {
throw new Error('Agent Manager already instantiated')
}
this.init({})
/** @type {import('./agent.js').Agent} */
this.agent = null
/** Device configuration processed from device yaml and options */
this.configuration = null
/** The original device yaml data as JS object */
this.provisioningExtras = null
}
init = (options) => {
options.version = options.version || require('../package.json').version
AgentManager.options = options
this._state = 'unknown'
this.agent = null
this.configuration = null
this.provisioningExtras = null
this.client = GOT.extend({})
return this
}
get options () {
return AgentManager.options
}
get state () {
if (this.exiting) { return 'exiting' }
return this._state
}
set state (state) {
if (this._state === 'exiting') { return } // don't update state once set to exiting
this._state = state
}
get exiting () {
return this._state === 'exiting'
}
async reloadConfig () {
if (this.exiting) { return }
if (!this.options) {
throw new Error('Agent Manager not initialised')
}
const config = require('./config').config(this.options)
// separate out the provisioningExtras object. everything else goes into configuration
const { provisioningExtras, ...configuration } = config
this.configuration = configuration
this.provisioningExtras = provisioningExtras
initLogger(this.configuration)
}
async startAgent () {
if (this.exiting) { return this.state }
if (!this.options) {
throw new Error('Agent Manager not initialised')
}
if (this.state === 'starting' || this.state === 'started') {
return this.state
}
this.state = 'starting'
info('Agent starting...')
try {
await this.reloadConfig()
info(`Version: ${this.configuration.version}`)
if (this.configuration.provisioningMode) {
info('Mode: Provisioning Mode')
} else {
info('Mode: Device Mode')
info(`Device: ${this.configuration.deviceId}`)
}
info(`ForgeURL: ${this.configuration.forgeURL}`)
debug({
...this.configuration,
...{
// Obscure any token/password type things from the log
token: this.configuration.token ? '*******' : undefined,
brokerPassword: this.configuration.brokerPassword ? '*******' : undefined,
credentialSecret: this.configuration.credentialSecret ? '*******' : undefined
}
})
if (this.exiting) { return this.state }
this.agent = agent.newAgent(this.configuration)
this.agent.AgentManager = this
if (this.exiting) {
this.agent && await this.agent.stop()
return this.state
}
this.state = await this.agent.start() || 'started'
return this.state
} catch (err) {
console.log(err.message)
process.exit(-1)
}
}
async stopAgent () {
if (this.agent) {
this.state = 'stopping'
await this.agent.stop()
}
this.state = 'stopping'
return true
}
async close () {
this.state = 'exiting'
if (this.agent) {
await this.agent.stop()
this.agent.AgentManager = null
this.agent = null
}
return true
}
/**
*
* @param {Number} delay - delay in milliseconds after stopping / before restarting the agent
*/
reloadAgent (delay = 500, callback) {
if (this.exiting) { return }
this.state = 'reloading'
// ensure delay is 20ms or more (default 500ms)
delay = Math.max(20, delay || 500)
this.cancelReload()
// on next tick, stop the agent, wait, then start it again
process.nextTick(async () => {
try {
await this.stopAgent()
if (this.exiting) { return }
this.reloadTimer = setTimeout(async () => {
if (this.exiting) { return }
this.state = await this.startAgent() || 'started'
if (callback) {
callback(null, this.state)
}
}, delay)
} catch (error) {
if (callback) {
callback(error, this.state)
}
}
}, delay)
}
cancelReload () {
this.reloadTimer && clearTimeout(this.reloadTimer)
}
async provisionDevice (provisioningURL, provisioningTeam, provisioningToken) {
debug('Provisioning device')
// sanity check the parameters
provisioningURL = provisioningURL || this.configuration.forgeURL
provisioningTeam = provisioningTeam || this.configuration.provisioningTeam
provisioningToken = provisioningToken || this.configuration.token
const provisioningConfig = {
provisioningMode: true,
provisioningTeam,
forgeURL: provisioningURL,
token: provisioningToken
}
let provisioningOK = false
let postResponse = null
try {
// before we do anything, check if the device can be provisioned
// These checks will ensure files are writable and the necessary settings are present
if (await this.canBeProvisioned(provisioningConfig) !== true) {
throw new Error('Device cannot be provisioned. Check the logs for more information.')
}
// Get the local IP address / MAC / Hostname of the device for use in naming
const { host, ip, mac, forgeOk, _error } = await this._getDeviceInfo(provisioningConfig.forgeURL, this.configuration?.token)
if (!forgeOk) {
throw new Error('Unable to connect to the Forge Platform', { cause: _error })
}
const type = 'Auto Provisioned' + (provisioningConfig.provisioningName ? ` [${provisioningConfig.provisioningName}]` : '')
const team = provisioningConfig.provisioningTeam
const name = ((host || ip) + (mac ? ` (${mac})` : '')) || 'New Device'
// Provision this device in the forge platform and receive the device ID, credentials and other details
postResponse = await this.client.post(`${provisioningConfig.forgeURL}/api/v1/devices`, {
headers: {
'user-agent': `FlowFuse Device Agent v${this.configuration?.version || ' unknown'}`,
authorization: `Bearer ${provisioningConfig.token}`
},
timeout: {
request: 10000
},
json: { name, type, team },
agent: getHTTPProxyAgent(provisioningConfig.forgeURL, { timeout: 10000 })
})
if (postResponse?.statusCode !== 200) {
throw new Error(`${postResponse.statusMessage} (${postResponse.statusCode})`)
}
provisioningOK = true
} catch (err) {
warn(`Problem encountered during provisioning: ${err.toString()}`)
warn(`Reason: ${err.response?.body || err.cause?.toString() || 'unknown'}`)
provisioningOK = false
}
if (!provisioningOK) {
// try again in 10 minutes
// * the device.yml file may have been fixed or updated
// * the server may be back online
info('Provisioning failed. Retrying in 10 minutes.')
this.reloadAgent(10 * 60 * 1000)
return false
}
try {
// * At this point, the device is created. We need to update the config, and restart the
// agent. If a problem occurs generating the device.yml, we need to end the program to
// avoid generating multiple devices on the platform
// * FUTURE: If generating the device.yml fails we should probably delete the device we
// just created. For now, we use the `canBeProvisioned()` check up front to avoid this problem
const provisioningData = JSON.parse(postResponse.body)
provisioningData.forgeURL = provisioningData.forgeURL || provisioningConfig.forgeURL
await this._provisionDevice(provisioningData)
this.reloadAgent(5000) // success - reload the device.yml
return true
} catch (err) {
warn(`Error provisioning device: ${err.toString()}`)
throw err
}
}
async quickConnectDevice () {
debug('Setting up device')
// sanity check the parameters
const provisioningConfig = {
quickConnectMode: true,
forgeURL: this.options.ffUrl,
token: Buffer.from(this.options.otc).toString('base64')
}
let success = false
let postResponse = null
const url = new URL('/api/v1/devices/', provisioningConfig.forgeURL)
try {
// before we do anything, check if the device can be provisioned
// These checks will ensure files are writable and the necessary settings are present
if (await this.canBeProvisioned(provisioningConfig) !== true) {
throw new Error('Device cannot be provisioned. Check the logs for more information.')
}
// Get the local IP address / MAC / Hostname of the device to generate a value for the agentHost field
const anEndpoint = new URL('/api/v1/settings/', provisioningConfig.forgeURL)
const { host, ip, forgeOk } = await this._getDeviceInfo(anEndpoint)
if (!forgeOk) {
throw new Error('Unable to connect to the FlowFuse Platform')
}
// Instruct platform to re-generate credentials for this device, passing in the agentHost field and the quickConnect flag
// NOTE: the OTC is passed in the Authorization header
// NOTE: the OTC token is 100% single use. The platform deletes it upon use regardless of the device-agent successfully writing the config file
postResponse = await this.client.post(url, {
headers: {
'user-agent': `FlowFuse Device Agent v${this.configuration?.version || ' unknown'}`,
authorization: `Bearer ${provisioningConfig.token}`
},
timeout: {
request: 10000
},
json: { setup: true, agentHost: host || ip },
agent: getHTTPProxyAgent(provisioningConfig.forgeURL, { timeout: 10000 })
})
if (postResponse?.statusCode !== 200) {
throw new Error(`${postResponse.statusMessage} (${postResponse.statusCode})`)
}
success = true
} catch (err) {
warn(`Problem encountered during provisioning: ${err.toString()}`)
success = false
}
if (!success) {
return false
}
try {
// * At this point, the one-time-code is spent (deleted) and we have all the info we need to update the config
const provisioningData = JSON.parse(postResponse.body)
provisioningData.forgeURL = provisioningData.forgeURL || provisioningConfig.forgeURL
await this._provisionDevice(provisioningData)
return true
} catch (err) {
warn(`Error provisioning device: ${err.toString()}`)
throw err
}
}
async canBeProvisioned (provisioningConfig) {
try {
if (!this.options) {
warn('Agent Manager not initialised. Device cannot be provisioned')
return false
}
const deviceFile = this.options.deviceFile
if (!deviceFile) {
warn('Device file not specified. Device cannot be provisioned')
return false
}
if (!provisioningConfig) {
warn('Provisioning config not specified. Device cannot be provisioned')
return false
}
// Using One-Time-code or provisioning token?
if (provisioningConfig.quickConnectMode) { // OTC mode
if (!this.options.otc) {
warn('One time code not specified. Device cannot be setup')
return false
}
if (!this.options.ffUrl) {
warn('FlowFuse URL not specified. Device cannot be setup')
return false
}
} else { // provisioning token mode
if (!provisioningConfig.provisioningMode || !provisioningConfig.provisioningTeam || !provisioningConfig.token) {
warn(`Credentials file '${deviceFile}' is not a valid provisioning file. Device cannot be provisioned`)
return false
}
}
if (!provisioningConfig.forgeURL) {
warn('FlowFuse URL not specified. Device cannot be provisioned')
return false
}
const deviceFileStat = pathStat(deviceFile)
if (deviceFileStat.fileExists && deviceFileStat.writable === false) {
warn(`Credentials file '${deviceFile}' cannot be written. Device cannot be provisioned`)
return false
} else if (deviceFileStat.parentDirectoryExists === false || deviceFileStat.parentDirectoryWritable === false) {
warn(`Credentials file '${deviceFile}' cannot be written. Device cannot be provisioned`)
return false
}
return true // all pre-provisioning checks passed
} catch (err) {
debug(`Problem checking if device can be provisioned: ${err.toString()}`)
}
return false
}
// #region Private Methods
async _getDeviceInfo (forgeURL, token) {
const ip2mac = {}
const result = { host: os.hostname(), ip: null, mac: null, forgeOk: false }
try {
const ifs = os.networkInterfaces()
let firstMacNotInternal = null
// eslint-disable-next-line no-unused-vars
for (const [name, ifaces] of Object.entries(ifs)) {
for (const iface of ifaces) {
if (iface.family === 'IPv4' || iface.family === 'IPv6') {
ip2mac[iface.address] = iface.mac
if (!firstMacNotInternal && !iface.internal) {
firstMacNotInternal = iface.mac
}
}
}
}
if (forgeURL) {
let forgeCheck
const headers = {
'user-agent': `FlowFuse Device Agent v${this.options?.version || ' unknown'}`
}
if (token) {
headers.authorization = `Bearer ${token}`
}
try {
forgeCheck = await this.client.get(forgeURL, {
headers,
timeout: {
request: 5000
},
agent: getHTTPProxyAgent(forgeURL, { timeout: 5000 })
})
result.ip = forgeCheck?.socket?.localAddress
result.mac = ip2mac[result.ip] || result.ip
result.mac = result.mac || firstMacNotInternal
} catch (_error) {
result._error = _error
forgeCheck = _error.response
}
if (token) {
result.forgeOk = [200, 204].includes(forgeCheck?.statusCode)
} else {
result.forgeOk = [200, 204, 401, 403, 404].includes(forgeCheck?.statusCode) // got _a_ response from the server, good enough for an existence check
}
}
} catch (err) {
// non fatal error, ignore and return what we have
}
return result
}
async _provisionDevice (platformProvisioningData) {
const credentials = platformProvisioningData.credentials
const forgeURL = platformProvisioningData.forgeURL
const deviceId = platformProvisioningData.id
const deviceJSPriority = {
deviceId,
forgeURL,
token: credentials.token,
credentialSecret: credentials.credentialSecret,
brokerURL: credentials.broker?.url,
brokerUsername: credentials.broker?.username,
brokerPassword: credentials.broker?.password,
autoProvisioned: true
}
// Generate deviceJs from ...this.provisioningExtras (extra config from the provisioning yaml) and the priority deviceJs
const deviceJS = { ...this.provisioningExtras, ...deviceJSPriority }
if (this.options?.otc) {
// when _provisionDevice is called, it was either cli-setup or auto-provisioned
// not both! delete the other flag
deviceJS.cliSetup = true
delete deviceJS.autoProvisioned
}
const deviceYAML = yaml.stringify(deviceJS)
const deviceFile = this.options.deviceFile
const backupFile = `${deviceFile}.bak`
const backupFileStat = pathStat(backupFile)
const skipBackup = backupFileStat.fileExists && backupFileStat.deletable !== true
// helper fn to delete a file without throwing errors
const deleteFile = (filename) => {
try {
fs.rmSync(filename)
} catch (error) {
// ignore error but log it
debug(`Error deleting file '${filename}': ${error.message}`)
}
}
// last chance to exit before rewriting the device file
if (this.exiting) { return }
if (skipBackup === false) {
if (backupFileStat.fileExists) {
deleteFile(backupFile) // quietly delete the backup file
backupFileStat.fileExists = false
}
try {
// move the current device file to a backup file
fs.renameSync(deviceFile, backupFile)
backupFileStat.fileExists = true
} catch (error) {
// ignore error but log it
debug(`Error backing up file '${deviceFile}': ${error.message}`)
}
}
try {
// 'w' flag definition: https://nodejs.org/docs/latest-v16.x/api/fs.html#file-system-flags
// Open the file for writing. The file is created
// (if it does not exist) or truncated (if it exists).
const fn = fs.openSync(deviceFile, 'w')
fs.writeFileSync(fn, deviceYAML, { encoding: 'utf8' })
fs.closeSync(fn)
} catch (error) {
throw new Error(`Error writing device file '${deviceFile}'`, error)
} finally {
if (backupFileStat.fileExists) {
deleteFile(backupFile) // quietly delete the backup file
}
}
}
// #endregion
}
/**
* Get stats for a file or directory
* @param {String} _path - path to file or directory to stat
* @returns {Object} - object with file/directory stats and error if any
*/
function pathStat (_path) {
let parentDirectory, fileExists, parentDirectoryExists, isFile, isDirectory, readable, writable, deletable, busy, error
let state = 'unknown'
try {
state = 'stat'
const resolved = path.resolve(_path)
parentDirectory = path.dirname(resolved)
parentDirectoryExists = fs.existsSync(parentDirectory)
if (!parentDirectoryExists) {
const err = new Error(`Directory '${parentDirectory}' does not exist`)
err.code = 'ENOENT'
throw err
}
isDirectory = false
isFile = false
let stats
try {
stats = fs.statSync(_path)
} catch (error) {
fileExists = false
}
if (stats) {
isDirectory = stats.isDirectory()
isFile = stats.isFile()
fileExists = isFile === true
}
if (isDirectory || fileExists) {
// check access to directory
state = 'read'
fs.accessSync(_path, fs.constants.R_OK)
readable = true
state = 'write'
fs.accessSync(_path, fs.constants.W_OK)
writable = true
busy = false
} else {
// file does not exist, check if we can create it
state = 'write'
const rw = fs.openSync(_path, 'a+') // Open file for reading and appending. The file is created if it does not exist.
fs.closeSync(rw)
readable = true
writable = true
// at this point, calls to fs.open 'a+' should have created the file
if (fs.existsSync(_path)) {
state = 'delete'
fs.rmSync(_path)
deletable = true
} else {
writable = false
deletable = false
}
}
} catch (err) {
error = err
switch (err.code) {
case 'ENOENT':
fileExists = false
if (state === 'write') {
// an error at this point normally means bad file name
// either way, we can't write to the file
writable = false
}
break
case 'EACCES':
case 'EPERM':
if (state === 'stat') {
error = err
} else if (state === 'delete') {
deletable = false
} else if (state === 'write') {
writable = false
} else if (state === 'read') {
readable = false
}
break
case 'EISDIR':
isFile = false
break
case 'EBUSY':
busy = true
break
}
}
deletable = writable && !busy
return { error, parentDirectory, parentDirectoryExists, isFile, fileExists, isDirectory, readable, writable, deletable, busy }
}
// create singleton
const agentManager = new AgentManager()
module.exports = { AgentManager: agentManager }