@flowfuse/device-agent
Version:
An Edge Agent for running Node-RED instances deployed from the FlowFuse Platform
357 lines (338 loc) • 11.9 kB
JavaScript
const fs = require('fs')
const path = require('path')
const ConfigLoader = require('../lib/config')
const { info, warn } = require('../lib/logging/log')
const REALM = 'Basic Authentication'
// #region Types
/**
* @typedef {import('./server').WebServer} WebServer
* @typedef {import('http').RequestListener} RequestListener
* @typedef {import('http').IncomingMessage | {$router: Router, $route: WebServerRoute} } WebServerRequest
* @typedef {import('http').ServerResponse} WebServerResponse
* @typedef {(
* req: WebServerRequest,
* res: WebServerResponse,
* ) => void} WebServerRouteHandler
*/
/**
* @typedef {Object} WebServerRoute
* @property {string} name
* @property {string} method
* @property {string} path
* @property {WebServer} server
* @property {Router} router
* @property {WebServerRouteHandler} handler
*/
// #endregion
// #region Routes
/** @type {WebServerRoute[]} */
const routes = [
{
name: 'index',
method: 'GET',
path: '/',
handler: (req, res) => {
if (!isAuthorized(req)) {
return respondWith401AuthRequired(res)
}
// redirect to home page
res.writeHead(302, { Location: '/home' })
res.end()
}
},
{
name: 'home',
method: 'GET',
path: '/home',
handler: (req, res) => {
if (!isAuthorized(req)) {
// redirect to index
res.writeHead(302, { Location: '/' })
res.end()
return
}
res.writeHead(200, { 'Content-Type': 'text/html' })
const homePage = path.join(__dirname, 'home.html')
const homePageData = fs.readFileSync(homePage, 'utf8')
res.write(homePageData)
res.end()
}
},
{
name: 'assets',
method: 'GET',
path: '/assets/*',
handler: (req, res) => {
const assetPath = path.join(__dirname, req.url)
if (!fs.existsSync(assetPath)) {
res.writeHead(404)
res.end()
return
}
// determine content type from file extension
const ext = path.extname(assetPath)
const contentType = contentTypeForExtension(ext)
if (!contentType) {
// unknown/restricted file type
res.writeHead(404)
res.end()
return
}
if (!contentType) {
// unknown/restricted file type
res.writeHead(404)
res.end()
return
}
// read the file using the correct encoding
const bufferEnc = encodingForContentType(contentType)
const assetData = fs.readFileSync(assetPath, bufferEnc)
res.writeHead(200, { 'Content-Type': contentType })
res.write(assetData, bufferEnc)
res.end()
}
},
{
name: 'status',
method: 'GET',
path: '/status',
handler: (req, res) => {
if (!isAuthorized(req)) {
// send json response with error
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Unauthorized' }))
return
}
// send json response with status
res.writeHead(200, { 'Content-Type': 'application/json' })
const agent = req.$router.server.agentManager?.agent || {}
const agentLoaded = !!req.$router.server.agentManager?.agent
const options = req.$router.server.agentManager?.options || {}
const config = agent.config || {}
const env = agent.currentSettings?.env || {}
const status = {
state: agentLoaded ? agent.currentState : 'stopped',
name: env.FF_DEVICE_NAME,
type: env.FF_DEVICE_TYPE,
mode: agent.currentMode,
version: options.version,
snapshotName: agent.currentSnapshot?.name,
snapshotDesc: agent.currentSnapshot?.description || undefined,
deviceClock: Date.now(),
// curated config view
config: {
deviceId: config.deviceId,
forgeURL: config.forgeURL,
dir: options.dir,
deviceFile: options.deviceFile,
port: options.port,
provisioningMode: config.provisioningMode,
provisioningName: config.provisioningName,
provisioningTeam: config.provisioningTeam
}
}
res.end(JSON.stringify({ success: true, status }))
}
},
{
name: 'submit',
method: 'POST',
path: '/submit',
handler: (req, res) => {
if (!isAuthorized(req)) {
return respondWith401Denied(res)
}
let body = ''
req.on('data', function (data) {
body += data
})
req.on('end', function () {
info('Received config update from Web UI')
// decode data sent by xhr.send('{ "config": "???" }')
const bodyData = decodeURIComponent(body)
const parsedBody = JSON.parse(bodyData)
// check the supplied data is a valid config
const parsedConfig = ConfigLoader.parseDeviceConfig(parsedBody.config)
if (parsedConfig.valid === false) {
warn('Invalid config provided by Web UI: ' + parsedConfig.message)
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: parsedConfig.message }))
return
}
// write file to disk
fs.writeFile(req.$router.options.deviceFile, parsedBody.config, (err) => {
if (err) {
warn('Failed to write config file to disk', err)
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err }))
return
}
// at this point, the config file has been written to disk. Reload the agent.
info('Config file written to disk. Reloading agent.')
req.$router.server.agentManager.reloadAgent(200, (err, state) => {
if (err) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err, state }))
return
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true, state }))
})
})
})
req.on('error', function (err) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err }))
})
}
}
]
// #endregion
// #region Router
class Router {
constructor () {
this.options = {}
/** @type {WebServerRoute[]} */
this.routes = []
/** @type {import('./server').WebServer} */
this.server = null
this.credentials = null
}
initialise (server, options) {
this.routes = []
this.server = server
this.options = options || {}
this.credentials = this.options.credentials ? { ...this.options.credentials } : null
this.runtime = this.options.runtime
this.startTime = Date.now()
const _routes = this.options.routes || routes
for (const _route of _routes) {
const route = { ..._route }
route.router = this
route.server = server
route.name = route.name || route.path
_route.handler = route.handler.bind(this)
this.routes.push(route)
}
}
/** @type {RequestListener} */
requestListener (req, res) {
let matchRoute = this.routes.find(route => route.method === req.method && route.path === req.url)
if (!matchRoute) {
matchRoute = this.routes.find(route => route.method === req.method && req.url.startsWith(route.path.slice(0, -1)) && route.path.endsWith('*'))
}
try {
if (matchRoute) {
req.$route = matchRoute
req.$router = matchRoute.router || this
matchRoute.handler(req, res)
} else {
res.writeHead(404)
res.end(JSON.stringify({ error: 'Not found' }))
}
} catch (err) {
console.error(err)
res.writeHead(500)
res.end(JSON.stringify({ error: 'Internal server error' }))
}
}
}
// #endregion
// #region Helpers
/**
* Writes a 401 response to the client and requests authentication
* @param {import('http').ServerResponse} res
* @returns {null}
*/
function respondWith401AuthRequired (res) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="' + REALM + '"' })
res.end('Authorization required')
return null
}
/**
* Writes a 401 response to the client and denies access
* @param {import('http').ServerResponse} res
* @returns {null}
*/
function respondWith401Denied (res) {
// send json response with error
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Unauthorized' }))
return null
}
/**
* Checks if the request is authorized
* @param {import('http').IncomingMessage} req
* @returns {boolean}
* @private
*/
function isAuthorized (req) {
const credentials = req.$router.credentials || { }
const auth = req.headers.authorization
if (!auth) {
return false
}
const parts = auth.split(' ')
const method = parts[0]
const encoded = parts[1]
const decoded = Buffer.from(encoded, 'base64').toString('utf-8')
const [username, password] = decoded.split(':')
return (method === 'Basic' && username === credentials.username && password === credentials.password)
}
/**
* Determines the encoding for a given content type
* @param {string} contentType - The content type to determine the encoding for
* @returns {string} - The encoding for the given content type
*/
function encodingForContentType (contentType) {
switch (contentType) {
case 'text/plain':
case 'text/html':
case 'text/css':
case 'application/json':
case 'application/x-yaml':
return 'utf8'
default:
return 'binary'
}
}
/**
* Determines the content type for a given file extension
* If the file extension is not recognised, null is returned (i.e. unsupported file type)
* @param {string} ext - The file extension
* @returns {string} - the content type for the given file extension
*/
function contentTypeForExtension (ext) {
switch (ext) {
case '.txt':
case '.log':
return 'text/plain'
case '.json':
return 'application/json'
case '.yaml':
case '.yml':
return 'application/x-yaml'
case '.pdf':
return 'application/pdf'
case '.css':
return 'text/css'
case '.html':
return 'text/html'
case '.png':
return 'image/png'
case '.jpg':
case '.jpeg':
return 'image/jpeg'
case '.gif':
return 'image/gif'
case '.svg':
return 'image/svg+xml'
case '.ico':
return 'image/x-icon'
}
return null
}
// #endregion
module.exports = {
Router
}