Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement quick connect in device agent #226

Merged
merged 11 commits into from
Feb 2, 2024
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@ mkdir c:\opt\flowfuse-device
### `device.yml` - for a single device

When the device is registered on the FlowFuse platform, a group of configuration
details are provided. These can be copied from the platform, or downloaded directly
as a yml file.
details are provided. These can be copied from the platform, downloaded directly
as a yml file or pulled from the FlowFuse server using the command issued by the
platform when you create a device. More details on this can be found
in the [FlowFuse documentation](https://flowfuse.com/docs/device-agent/introduction/).

This file should be copied into the working directory as `device.yml`.
This file should exist in the working directory as `device.yml`.

A different config file can be specified with the `-c/--config` option.

Expand Down Expand Up @@ -220,6 +222,11 @@ Web UI Options
--ui-pass string Web UI password. Required if --ui is specified
--ui-runtime mins Time the Web UI server is permitted to run. Default: 10

Setup command

-o, --otc string Setup device using a one time code
-u, --ff-url url URL of FlowFuse. Required for setup
knolleary marked this conversation as resolved.
Show resolved Hide resolved

Global Options

-h, --help print out helpful usage information
Expand Down
36 changes: 35 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ if (semver.lt(process.version, '14.0.0')) {

const TESTING = process.env.NODE_ENV === 'test'
const commandLineArgs = require('command-line-args')
const { info } = require('./lib/log')
const { info, warn } = require('./lib/log')
const { hasProperty } = require('./lib/utils')
const path = require('path')
const fs = require('fs')
const { AgentManager } = require('./lib/AgentManager')
Expand Down Expand Up @@ -101,6 +102,39 @@ Please ensure the parent directory is writable, or set a different path with -d`
delete options.config
AgentManager.init(options)

if (hasProperty(options, 'otc') || hasProperty(options, 'ffUrl')) {
// Quick Connect mode
if (!options.otc || options.otc.length < 8) {
// 8 is the minimum length of an OTC
// e.g. ab-cd-ef
warn('Device setup requires parameter --otc to be 8 or more characters')
quit(null, 2)
}
info('Entering Device setup...')
if (!options.ffUrl) {
warn('Device setup requires parameter --ff-url to be set')
quit(null, 2)
}
AgentManager.quickConnectDevice().then((success) => {
if (success) {
const runCommandInfo = ['flowfuse-device-agent']
if (options.dir !== '/opt/flowfuse-device') {
runCommandInfo.push(`-d ${options.dir}`)
}
info('Device setup was successful')
info('To start the Device Agent with the new configuration run the following command:')
info(runCommandInfo.join(' '))
quit()
} else {
warn('Device setup was unsuccessful')
quit(null, 2)
}
}).catch((err) => {
quit(err.message, 2)
})
return
}

info('FlowFuse Device Agent')
info('----------------------')

Expand Down
167 changes: 133 additions & 34 deletions lib/AgentManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class AgentManager {
}

// Get the local IP address / MAC / Hostname of the device for use in naming
const { host, ip, mac, forgeOk } = await this._getDeviceInfo(provisioningConfig.forgeURL)
const { host, ip, mac, forgeOk } = await this._getDeviceInfo(provisioningConfig.forgeURL, this.configuration?.token)
if (!forgeOk) {
throw new Error('Unable to connect to the Forge Platform')
}
Expand Down Expand Up @@ -234,6 +234,72 @@ class AgentManager {
}
}

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
knolleary marked this conversation as resolved.
Show resolved Hide resolved
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 got.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 }
})

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) {
Expand All @@ -245,12 +311,28 @@ class AgentManager {
warn('Device file not specified. Device cannot be provisioned')
return false
}
if (!provisioningConfig || !provisioningConfig.provisioningMode || !provisioningConfig.provisioningTeam || !provisioningConfig.token) {
warn(`Credentials file '${deviceFile}' is not a valid provisioning file. Device cannot be provisioned`)
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('Forge URL not specified. Device cannot be provisioned')
warn('FlowFuse URL not specified. Device cannot be provisioned')
return false
}
const deviceFileStat = pathStat(deviceFile)
Expand All @@ -269,43 +351,54 @@ class AgentManager {
}

// #region Private Methods
async _getDeviceInfo (forgeURL) {
const ifs = os.networkInterfaces()
async _getDeviceInfo (forgeURL, token) {
const ip2mac = {}
const result = { host: os.hostname(), ip: null, mac: null, forgeOk: false }

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
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
try {
forgeCheck = await got.get(forgeURL, {
headers: {
'user-agent': `FlowFuse Device Agent v${this.configuration.version}`,
authorization: `Bearer ${this.configuration.token}`
},
timeout: {
request: 5000
}
})
result.ip = forgeCheck?.socket?.localAddress
result.mac = ip2mac[result.ip] || result.ip
result.mac = result.mac || firstMacNotInternal
} catch (_error) {
// ignore
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 got.get(forgeURL, {
headers,
timeout: {
request: 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
}
}
result.forgeOk = forgeCheck?.statusCode === 200
} catch (err) {
// non fatal error, ignore and return what we have
}
return result
}
Expand All @@ -324,6 +417,12 @@ class AgentManager {
brokerPassword: credentials.broker?.password,
autoProvisioned: true
}
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`
Expand Down
16 changes: 16 additions & 0 deletions lib/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,21 @@ module.exports = [
typeLabel: '{underline mins}',
defaultValue: 10,
group: 'ui'
},
{
name: 'otc',
description: 'Setup device using a one time code',
type: String,
alias: 'o',
typeLabel: '{underline string}',
group: 'setup'
},
{
name: 'ff-url',
description: 'URL of FlowFuse. Required for setup',
type: String,
alias: 'u',
typeLabel: '{underline url}',
group: 'setup'
}
]
5 changes: 5 additions & 0 deletions lib/cli/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ module.exports = {
optionList: require('./args'),
group: ['ui']
},
{
header: 'Setup command',
optionList: require('./args'),
group: ['setup']
},
{
header: 'Global Options',
optionList: require('./args'),
Expand Down
3 changes: 2 additions & 1 deletion lib/log.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
let verbose = false
function log (msg, level) {
console.log(`[AGENT] ${new Date().toLocaleString([], { dateStyle: 'medium', timeStyle: 'medium' })} ${level || 'info'}:`, msg)
const date = new Date()
console.log(`[AGENT] ${date.toLocaleDateString()} ${date.toLocaleTimeString()} [${level || 'info'}] ${msg}`)
}
module.exports = {
initLogger: configuration => { verbose = configuration.verbose },
Expand Down
Loading
Loading