diff --git a/front/src/assets/integrations/cover/node-red.jpg b/front/src/assets/integrations/cover/node-red.jpg new file mode 100644 index 0000000000..631b9bdbb0 Binary files /dev/null and b/front/src/assets/integrations/cover/node-red.jpg differ diff --git a/front/src/assets/integrations/logos/logo_node-red.png b/front/src/assets/integrations/logos/logo_node-red.png new file mode 100644 index 0000000000..c9b1afb1f5 Binary files /dev/null and b/front/src/assets/integrations/logos/logo_node-red.png differ diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index 4feb47605f..a63c43cccd 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -145,6 +145,9 @@ import MELCloudEditPage from '../routes/integration/all/melcloud/edit-page'; import MELCloudSetupPage from '../routes/integration/all/melcloud/setup-page'; import MELCloudDiscoverPage from '../routes/integration/all/melcloud/discover-page'; +// NodeRed integration +import NodeRedPage from '../routes/integration/all/node-red/setup-page'; + const defaultState = getDefaultState(); const store = createStore(defaultState); @@ -251,6 +254,9 @@ const AppRouter = connect( + + + diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 7be2e943e0..2665d57dd9 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -632,6 +632,7 @@ "discover": { "title": "Zigbee2mqtt discover", "permitJoin": "Permit joining", + "hideExistingDevices": "Hide already added devices", "noDeviceDiscovered": "No new device discovered, please click on scan button.", "serverNoResponse": "Gladys server is not available.", "serverNoResponseWebSocker": "Gladys server is not available. Pleash refresh the page.", @@ -652,6 +653,42 @@ "saveError": "There was an error saving the device.", "documentation": "Zigbee2mqtt documentation" }, + "nodeRed": { + "title": "Node-RED", + "description": "Control your devices with Node-RED.", + "setupTab": "Setup", + "documentation": "Node-RED documentation", + + "status": { + "notInstalled": "Node-RED server failed to install.", + "notRunning": "Node-RED server failed to start.", + "running": "Node-RED successfully started.", + "notEnabled": "Node-RED is not activated.", + "nonDockerEnv": "Gladys is not running on Docker, you cannot install a Node-RED server from here.", + "invalidDockerNetwork": "Gladys is under custom installation, to install server from here, Gladys container should be configured with \"host\" network mode." + }, + "setup": { + "title": "Node-RED configuration", + "description": "This service uses docker container. Enable Node-RED for deploying this container.\nLearn more on the node-red documentation page", + "descriptionBackup": "For the moment, Node-RED is not backup into Gladys Plus.\nIf you want a backup for Node-RED, please follow this docs", + "error": "An error occured while starting Node-RED.", + "enableLabel": "Node-RED activation", + "enableNodeRed": "Enable", + "disableNodeRed": "Disable", + "confirmDisableLabel": "Are you sur you to disable Node-RED ?", + "confirmDisableCancelButton": "Cancel", + "activationNodeRed": "Activating...", + "serviceStatus": "Node-RED Service Status", + "containersStatus": "Containers related to Node-RED", + "status": "Status", + "node-red": "Node-RED", + "gladys": "Gladys", + "usernameLabel": "Username", + "passwordLabel": "Password", + "urlLabel": "Node-RED interface url: {{nodeRedUrl}} (Not accessible from Gladys Plus)" + } + }, + "googleHome": { "title": "Google Home", "description": "Control your Gladys device with your voice in Google Home", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index fedbb2d787..74a0378dac 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -758,6 +758,7 @@ "discover": { "title": "Découverte du réseau Zigbee", "permitJoin": "Autoriser l'association", + "hideExistingDevices": "Cacher les appareils déjà ajoutés", "noDeviceDiscovered": "Aucun nouvel appareil trouvé. Veuillez autoriser l'association des appareils et les appairer.\nATTENTION : Pour la sécurité de votre installation, n'oubliez pas d'interdire l'association après appairage.", "serverNoResponse": "Gladys n'est pas accessible.", "serverNoResponseWebSocker": "Gladys n'est pas accessible. Veuillez actualiser la page.", @@ -778,6 +779,41 @@ "saveError": "Une erreur s'est produite lors de la sauvegarde.", "documentation": "Documentation Zigbee2mqtt" }, + "nodeRed": { + "title": "Node-RED", + "description": "Contrôlez vos appareils via Node-RED.", + "setupTab": "Configuration", + "documentation": "Documentation Node-RED", + "status": { + "notInstalled": "Le serveur Node-RED n'a pas pu être installé.", + "notRunning": "Le serveur Node-RED n'a pas démarré.", + "running": "Node-RED démarré avec succès.", + "notEnabled": "Node-RED n'est pas activé.", + "nonDockerEnv": "Gladys ne s'exécute actuellement pas dans Docker, il est impossible d'activer Node-RED depuis Gladys.", + "invalidDockerNetwork": "Gladys est basée sur une installation personalisée, pour installer Node-RED depuis cette page, le conteneur Docker de Gladys doit être démarré avec l'option \"network=host\"." + }, + "setup": { + "title": "Configuration du service Node-RED", + "description": "Ce service utilise un container Docker. Activez Node-RED pour déployer ce container.\nPour en savoir plus, rendez-vous sur la page de documentation Node-RED.", + "descriptionBackup": "Pour le moment, Node-RED n'est pas sauvegardé dans Gladys Plus.\nSi vous souhaitez une sauvegarde pour Node-RED, veuillez suivre cette documentation", + "error": "Une erreur s'est produite au démarrage du service Node-RED.", + "enableLabel": "Activation du service Node-RED", + "enableNodeRed": "Activer", + "disableNodeRed": "Désactiver", + "confirmDisableLabel": "Etes-vous sûr de vouloir désactiver Node-RED ?", + "confirmDisableCancelButton": "Annuler", + "activationNodeRed": "Activation...", + "serviceStatus": "Etat du service Node-RED", + "containersStatus": "Conteneurs liés à Node-RED", + "status": "Status", + "node-red": "Node-RED", + "gladys": "Gladys", + "usernameLabel": "Nom d'utilisateur", + "passwordLabel": "Mot de passe", + "urlLabel": "Url de l'interface Node-RED : {{nodeRedUrl}} (Pas accessible depuis Gladys Plus)" + } + }, + "googleHome": { "title": "Google Home", "description": "Contrôlez vos appareils Gladys à la voix dans Google Home", diff --git a/front/src/config/integrations/devices.json b/front/src/config/integrations/devices.json index 0fc960da62..95915d48de 100644 --- a/front/src/config/integrations/devices.json +++ b/front/src/config/integrations/devices.json @@ -68,5 +68,10 @@ "key": "melcloud", "link": "melcloud", "img": "/assets/integrations/cover/melcloud.jpg" + }, + { + "key": "nodeRed", + "link": "node-red", + "img": "/assets/integrations/cover/node-red.jpg" } ] diff --git a/front/src/routes/integration/all/node-red/NodeRedPage.js b/front/src/routes/integration/all/node-red/NodeRedPage.js new file mode 100644 index 0000000000..3743472d33 --- /dev/null +++ b/front/src/routes/integration/all/node-red/NodeRedPage.js @@ -0,0 +1,50 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import DeviceConfigurationLink from '../../../../components/documentation/DeviceConfigurationLink'; + +const NodeRedPage = ({ children, user }) => ( +
+
+
+
+
+
+

+ +

+
+
+ + + + + + + + + + + + + +
+
+
+ +
{children}
+
+
+
+
+
+); + +export default NodeRedPage; diff --git a/front/src/routes/integration/all/node-red/setup-page/CheckStatus.js b/front/src/routes/integration/all/node-red/setup-page/CheckStatus.js new file mode 100644 index 0000000000..6f540312be --- /dev/null +++ b/front/src/routes/integration/all/node-red/setup-page/CheckStatus.js @@ -0,0 +1,51 @@ +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import { RequestStatus } from '../../../../../utils/consts'; +import style from './style.css'; +import classNames from 'classnames/bind'; + +let cx = classNames.bind(style); + +class CheckStatus extends Component { + render({ nodeRedEnabled, nodeRedExist, nodeRedRunning, dockerBased, networkModeValid, nodeRedStatus }, {}) { + let textLabel = null; + if (nodeRedStatus === RequestStatus.Getting) { + textLabel = 'integration.nodeRed.setup.activationNodeRed'; + } else if (!dockerBased) { + textLabel = 'integration.nodeRed.status.nonDockerEnv'; + } else if (!networkModeValid) { + textLabel = 'integration.nodeRed.status.invalidDockerNetwork'; + } else if (nodeRedEnabled) { + if (!nodeRedExist) { + textLabel = 'integration.nodeRed.status.notInstalled'; + } else if (!nodeRedRunning) { + textLabel = 'integration.nodeRed.status.notRunning'; + } else { + textLabel = 'integration.nodeRed.status.running'; + } + } else { + textLabel = 'integration.nodeRed.status.notEnabled'; + } + + return ( +
+
+
+ + + +
+
+
+ ); + } +} + +export default CheckStatus; diff --git a/front/src/routes/integration/all/node-red/setup-page/SetupTab.jsx b/front/src/routes/integration/all/node-red/setup-page/SetupTab.jsx new file mode 100644 index 0000000000..a1fc21345a --- /dev/null +++ b/front/src/routes/integration/all/node-red/setup-page/SetupTab.jsx @@ -0,0 +1,441 @@ +import { Component } from 'preact'; +import { Text, MarkupText, Localizer } from 'preact-i18n'; +import { RequestStatus } from '../../../../../utils/consts'; +import CheckStatus from './CheckStatus.js'; +import classNames from 'classnames/bind'; +import style from './style.css'; +import get from 'get-value'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import config from '../../../../../config'; + +let cx = classNames.bind(style); + +class SetupTab extends Component { + componentDidMount = () => { + this.checkStatus(); + this.getConfiguration(); + }; + + getConfiguration = async () => { + try { + const nodeRedUsernameVariable = await this.props.httpClient.get( + '/api/v1/service/node-red/variable/NODE_RED_USERNAME' + ); + const nodeRedPasswordVariable = await this.props.httpClient.get( + '/api/v1/service/node-red/variable/NODE_RED_PASSWORD' + ); + + const isGladysPlus = this.props.session.gatewayClient !== undefined; + let nodeRedUrl = null; + + if (isGladysPlus === false) { + const nodeRedPortVariable = await this.props.httpClient.get('/api/v1/service/node-red/variable/NODE_RED_PORT'); + + const url = new URL(config.localApiUrl); + nodeRedUrl = `${url.protocol}//${url.hostname}:${nodeRedPortVariable.value}`; + } + + this.setState({ + nodeRedUsername: nodeRedUsernameVariable.value, + nodeRedPassword: nodeRedPasswordVariable.value, + nodeRedUrl + }); + } catch (e) { + // Variable is not set yet + } + }; + + async componentWillMount() { + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, this.checkStatus); + } + + componentWillUnmount = () => { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, this.checkStatus); + if (this.showPasswordTimer) { + clearTimeout(this.showPasswordTimer); + this.showPasswordTimer = null; + } + }; + + toggle = () => { + let checked = this.state.nodeRedEnabled; + checked = !checked; + + if (checked) { + this.startContainer(); + } else { + this.stopContainer(); + } + }; + + startContainer = async () => { + let error = false; + + this.setState({ + nodeRedStatus: RequestStatus.Getting + }); + + await this.props.httpClient.post('/api/v1/service/node-red/variable/NODERED_ENABLED', { + value: true + }); + + try { + await this.props.httpClient.post('/api/v1/service/node-red/connect'); + } catch (e) { + error = error | get(e, 'response.status'); + } + + if (error) { + this.setState({ + nodeRedStatus: RequestStatus.Error + }); + } else { + this.setState({ + nodeRedStatus: RequestStatus.Success + }); + } + await this.getConfiguration(); + await this.checkStatus(); + }; + + stopContainer = async () => { + await this.props.httpClient.post('/api/v1/service/node-red/variable/NODERED_ENABLED', { + value: false + }); + + let error = false; + try { + await this.props.httpClient.post('/api/v1/service/node-red/disconnect'); + } catch (e) { + error = error | get(e, 'response.status'); + } + + if (error) { + this.setState({ + nodeRedStatus: RequestStatus.Error + }); + } else { + this.setState({ + nodeRedStatus: RequestStatus.Success + }); + } + this.setState({ showConfirmDelete: false }); + await this.getConfiguration(); + await this.checkStatus(); + }; + + checkStatus = async () => { + let nodeRedStatus = { + nodeRedExist: false, + nodeRedRunning: false, + nodeRedEnabled: false, + dockerBased: false, + networkModeValid: false + }; + try { + nodeRedStatus = await this.props.httpClient.get('/api/v1/service/node-red/status'); + } finally { + this.setState({ + nodeRedExist: nodeRedStatus.nodeRedExist, + nodeRedRunning: nodeRedStatus.nodeRedRunning, + nodeRedEnabled: nodeRedStatus.nodeRedEnabled, + dockerBased: nodeRedStatus.dockerBased, + networkModeValid: nodeRedStatus.networkModeValid + }); + } + }; + + togglePassword = () => { + const { showPassword } = this.state; + + if (this.showPasswordTimer) { + clearTimeout(this.showPasswordTimer); + this.showPasswordTimer = null; + } + + this.setState({ showPassword: !showPassword }); + + if (!showPassword) { + this.showPasswordTimer = setTimeout(() => this.setState({ showPassword: false }), 5000); + } + }; + + showConfirmDelete = () => { + this.setState({ showConfirmDelete: true }); + }; + + cancelDisable = () => { + this.setState({ showConfirmDelete: false }); + }; + + render( + props, + { + nodeRedEnabled, + dockerBased, + networkModeValid, + nodeRedExist, + nodeRedRunning, + nodeRedUsername, + nodeRedPassword, + nodeRedUrl, + nodeRedStatus, + showPassword, + showConfirmDelete + } + ) { + return ( +
+
+

+ +

+
+
+

+ +

+ +
+
+ + + +
+
+ + + + {nodeRedRunning && ( +
+
+ + + + +
+ +
+ +
+ + + + + + +
+
+ +
+ {nodeRedUrl && ( + + )} +
+
+ )} + + {dockerBased && networkModeValid && nodeRedEnabled && !showConfirmDelete && ( + + )} + {dockerBased && networkModeValid && !nodeRedEnabled && !showConfirmDelete && ( + + )} + {dockerBased && networkModeValid && nodeRedEnabled && showConfirmDelete && ( +
+ +
+ + +
+
+ )} + {nodeRedRunning && ( +
+
+

+ +

+
+
+
+ + + + + + + + + + + {nodeRedEnabled && ( + + )} + + + + + + + +
+ + + {nodeRedEnabled && 'Node-RED'}
+ {`Gladys`} + +
+ +
+
+ {nodeRedEnabled && ( + {`Node-RED`} + )} +
+
+ +
+
+ + {nodeRedRunning && ( + + + + )} +
+
+
+
+

+ +

+
+
+
+ + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + +
+ + + {nodeRedRunning && ( + + + + )} +
+
+
+
+ )} +
+
+ ); + } +} + +export default SetupTab; diff --git a/front/src/routes/integration/all/node-red/setup-page/index.js b/front/src/routes/integration/all/node-red/setup-page/index.js new file mode 100644 index 0000000000..80c1717a55 --- /dev/null +++ b/front/src/routes/integration/all/node-red/setup-page/index.js @@ -0,0 +1,16 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import NodeRedPage from '../NodeRedPage'; +import SetupTab from './SetupTab'; + +class NodeRedSetupPage extends Component { + render(props, {}) { + return ( + + + + ); + } +} + +export default connect('user,session,httpClient', {})(NodeRedSetupPage); diff --git a/front/src/routes/integration/all/node-red/setup-page/style.css b/front/src/routes/integration/all/node-red/setup-page/style.css new file mode 100644 index 0000000000..7bc7460d98 --- /dev/null +++ b/front/src/routes/integration/all/node-red/setup-page/style.css @@ -0,0 +1,32 @@ +.tdCenter { + vertical-align: middle; + display: flex; + align-items: center; +} + +.textAlignMiddleContainer { + display: table; +} + +.textAlignMiddle { + display: table-cell; + vertical-align: middle; +} + +.greenIcon { + color: #5eba00;; + font-size: 24px; +} + +.redIcon { + color: #cd201f; + font-size: 24px; +} + +.line { + color: #555; + background-color: #555; + border-color: #555; + height: 1px; + width: 40px; +} diff --git a/front/src/routes/integration/all/zigbee2mqtt/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/zigbee2mqtt/discover-page/DiscoverTab.jsx index 789753dcb8..c429e8d597 100644 --- a/front/src/routes/integration/all/zigbee2mqtt/discover-page/DiscoverTab.jsx +++ b/front/src/routes/integration/all/zigbee2mqtt/discover-page/DiscoverTab.jsx @@ -30,6 +30,23 @@ const DiscoverTab = ({ children, ...props }) => ( +
    +
  • + +
  • +
diff --git a/front/src/routes/integration/all/zigbee2mqtt/discover-page/actions.js b/front/src/routes/integration/all/zigbee2mqtt/discover-page/actions.js index 37ba70c890..585963fa11 100644 --- a/front/src/routes/integration/all/zigbee2mqtt/discover-page/actions.js +++ b/front/src/routes/integration/all/zigbee2mqtt/discover-page/actions.js @@ -13,7 +13,10 @@ function createActions(store) { }); try { - const zigbee2mqttDevices = await state.httpClient.get('/api/v1/service/zigbee2mqtt/discovered'); + const { filterExisting = true } = state; + const zigbee2mqttDevices = await state.httpClient.get('/api/v1/service/zigbee2mqtt/discovered', { + filter_existing: filterExisting + }); store.setState({ zigbee2mqttDevices, discoverZigbee2mqtt: false }); } catch (e) { store.setState({ @@ -23,7 +26,22 @@ function createActions(store) { }); } }, - setDiscoveredDevices(state, zigbee2mqttDevices) { + async toggleFilterOnExisting(state = {}) { + const { filterExisting = true } = state; + store.setState({ + filterExisting: !filterExisting + }); + + await actions.getDiscoveredDevices(store.getState()); + }, + setDiscoveredDevices(state = {}, incomingDevices) { + const { filterExisting = true } = state; + + let zigbee2mqttDevices = incomingDevices; + if (incomingDevices && filterExisting) { + zigbee2mqttDevices = zigbee2mqttDevices.filter(device => device.id === undefined || device.updatable); + } + store.setState({ zigbee2mqttDevices, discoverZigbee2mqtt: false, diff --git a/front/src/routes/integration/all/zigbee2mqtt/discover-page/index.js b/front/src/routes/integration/all/zigbee2mqtt/discover-page/index.js index 1c47ac44dc..3605cd2d6b 100644 --- a/front/src/routes/integration/all/zigbee2mqtt/discover-page/index.js +++ b/front/src/routes/integration/all/zigbee2mqtt/discover-page/index.js @@ -47,7 +47,7 @@ class Zigbee2mqttIntegration extends Component { export default withIntlAsProp( connect( - 'user,session,houses,zigbee2mqttDevices,discoverZigbee2mqtt,discoverZigbee2mqttError,permitJoin,gladysConnected,zigbee2mqttConnected,usbConfigured,z2mEnabled', + 'user,session,houses,zigbee2mqttDevices,discoverZigbee2mqtt,discoverZigbee2mqttError,permitJoin,gladysConnected,zigbee2mqttConnected,usbConfigured,z2mEnabled,filterExisting', actions )(Zigbee2mqttIntegration) ); diff --git a/server/services/index.js b/server/services/index.js index c335d2850e..83c929aee9 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -22,3 +22,4 @@ module.exports['lan-manager'] = require('./lan-manager'); module.exports['nextcloud-talk'] = require('./nextcloud-talk'); module.exports.tuya = require('./tuya'); module.exports.melcloud = require('./melcloud'); +module.exports['node-red'] = require('./node-red'); diff --git a/server/services/node-red/api/node-red.controller.js b/server/services/node-red/api/node-red.controller.js new file mode 100644 index 0000000000..e35ae130dd --- /dev/null +++ b/server/services/node-red/api/node-red.controller.js @@ -0,0 +1,73 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); +const logger = require('../../../utils/logger'); + +module.exports = function NodeRedController(gladys, nodeRedManager) { + /** + * @api {get} /api/v1/service/node-red/status Get node-red connection status + * @apiName status + * @apiGroup Node-RED + */ + async function status(req, res) { + logger.debug('Get status'); + const response = await nodeRedManager.status(); + res.json(response); + } + + /** + * @api {post} /api/v1/service/node-red/connect Connect + * @apiName connect + * @apiGroup Node-RED + */ + async function connect(req, res) { + logger.debug('Entering connect step'); + await nodeRedManager.init(); + res.json({ + success: true, + }); + } + + /** + * @api {post} /api/v1/service/node-red/start Install & start Node-RED container. + * @apiName installNodeRedContainer + * @apiGroup Node-RED + */ + async function installNodeRedContainer(req, res) { + logger.debug('Install Node-RED container'); + await nodeRedManager.installContainer(); + res.json({ + success: true, + }); + } + + /** + * @api {post} /api/v1/service/node-red/disconnect Disconnect + * @apiName disconnect + * @apiGroup Node-RED + */ + async function disconnect(req, res) { + logger.debug('Entering disconnect step'); + await nodeRedManager.disconnect(); + res.json({ + success: true, + }); + } + + return { + 'get /api/v1/service/node-red/status': { + authenticated: true, + controller: asyncMiddleware(status), + }, + 'post /api/v1/service/node-red/connect': { + authenticated: true, + controller: asyncMiddleware(connect), + }, + 'post /api/v1/service/node-red/start': { + authenticated: true, + controller: asyncMiddleware(installNodeRedContainer), + }, + 'post /api/v1/service/node-red/disconnect': { + authenticated: true, + controller: asyncMiddleware(disconnect), + }, + }; +}; diff --git a/server/services/node-red/docker/gladys-node-red-container.json b/server/services/node-red/docker/gladys-node-red-container.json new file mode 100644 index 0000000000..efef54130a --- /dev/null +++ b/server/services/node-red/docker/gladys-node-red-container.json @@ -0,0 +1,37 @@ +{ + "name": "gladys-node-red", + "Image": "nodered/node-red:3.1", + "ExposedPorts": { + "1880/tcp": {} + }, + "HostConfig": { + "Binds": ["/var/lib/gladysassistant/node-red:/data"], + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m" + } + }, + "PortBindings": { + "1880/tcp": [ + { + "HostPort": "1881" + } + ] + }, + "RestartPolicy": { + "Name": "always" + }, + + "Dns": [], + "DnsOptions": [], + "DnsSearch": [], + "BlkioWeightDevice": [], + "Devices": [] + }, + "NetworkDisabled": false, + "AttachStdin": false, + "AttachStdout": false, + "AttachStderr": false, + "Tty": false +} diff --git a/server/services/node-red/docker/settings.txt b/server/services/node-red/docker/settings.txt new file mode 100644 index 0000000000..a62dd14475 --- /dev/null +++ b/server/services/node-red/docker/settings.txt @@ -0,0 +1,597 @@ +/** + * This is the default settings file provided by Node-RED. + * + * It can contain any valid JavaScript code that will get run when Node-RED + * is started. + * + * Lines that start with // are commented out. + * Each entry should be separated from the entries above and below by a comma ','. + * + * For more information about individual settings, refer to the documentation: + * https://nodered.org/docs/user-guide/runtime/configuration. + * + * The settings are split into the following sections: + * - Flow File and User Directory Settings + * - Security + * - Server Settings + * - Runtime Settings + * - Editor Settings + * - Node Settings. + * + */ + +module.exports = { + +/** + * *****************************************************************************. + * Flow File and User Directory Settings + * - flowFile + * - credentialSecret + * - flowFilePretty + * - userDir + * - nodesDir + *****************************************************************************. + */ + + /** The file containing the flows. If not set, defaults to flows_.json */ + flowFile: 'flows.json', + + /** + * By default, credentials are encrypted in storage using a generated key. To + * specify your own secret, set the following property. + * If you want to disable encryption of credentials, set this property to false. + * Note: once you set this property, do not change it - doing so will prevent + * node-red from being able to decrypt your existing credentials and they will be + * lost. + */ + // credentialSecret: "a-secret-key", + + /** + * By default, the flow JSON will be formatted over multiple lines making + * it easier to compare changes when using version control. + * To disable pretty-printing of the JSON set the following property to false. + */ + flowFilePretty: true, + + /** + * By default, all user data is stored in a directory called `.node-red` under + * the user's home directory. To use a different location, the following + * property can be used. + */ + // userDir: '/home/nol/.node-red/', + + /** + * Node-RED scans the `nodes` directory in the userDir to find local node files. + * The following property can be used to specify an additional directory to scan. + */ + // nodesDir: '/home/nol/.node-red/nodes', + +/** + * *****************************************************************************. + * Security + * - adminAuth + * - https + * - httpsRefreshInterval + * - requireHttps + * - httpNodeAuth + * - httpStaticAuth + *****************************************************************************. + */ + + /** + * To password protect the Node-RED editor and admin API, the following + * property can be used. See http://nodered.org/docs/security.html for details. + */ + adminAuth: { + type: 'credentials', + users: [{ + username: 'admin', + password: '$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN.', + permissions: '*' + }] + }, + + /** + * The following property can be used to enable HTTPS + * This property can be either an object, containing both a (private) key + * and a (public) certificate, or a function that returns such an object. + * See http://nodejs.org/api/https.html#https_https_createserver_options_requestlistener + * for details of its contents. + */ + + /** Option 1: static object */ + // https: { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // }, + + /** Option 2: function that returns the HTTP configuration object */ + // https: function() { + // // This function should return the options object, or a Promise + // // that resolves to the options object + // return { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // } + // }, + + /** + * If the `https` setting is a function, the following setting can be used + * to set how often, in hours, the function will be called. That can be used + * to refresh any certificates. + */ + // httpsRefreshInterval : 12, + + /** + * The following property can be used to cause insecure HTTP connections to + * be redirected to HTTPS. + */ + // requireHttps: true, + + /** + * To password protect the node-defined HTTP endpoints (httpNodeRoot), + * including node-red-dashboard, or the static content (httpStatic), the + * following properties can be used. + * The `pass` field is a bcrypt hash of the password. + * See http://nodered.org/docs/security.html#generating-the-password-hash. + */ + // httpNodeAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + // httpStaticAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + +/** + * *****************************************************************************. + * Server Settings + * - uiPort + * - uiHost + * - apiMaxLength + * - httpServerOptions + * - httpAdminRoot + * - httpAdminMiddleware + * - httpNodeRoot + * - httpNodeCors + * - httpNodeMiddleware + * - httpStatic + * - httpStaticRoot + *****************************************************************************. + */ + + /** The tcp port that the Node-RED web server is listening on */ + uiPort: process.env.PORT || 1880, + + /** + * By default, the Node-RED UI accepts connections on all IPv4 interfaces. + * To listen on all IPv6 addresses, set uiHost to "::", + * The following property can be used to listen on a specific interface. For + * example, the following would only allow connections from the local machine. + */ + uiHost: "0.0.0.0", + + /** + * The maximum size of HTTP request that will be accepted by the runtime api. + * Default: 5mb. + */ + // apiMaxLength: '5mb', + + /** + * The following property can be used to pass custom options to the Express.js + * server used by Node-RED. For a full list of available options, refer + * to http://expressjs.com/en/api.html#app.settings.table. + */ + // httpServerOptions: { }, + + /** + * By default, the Node-RED UI is available at http://localhost:1880/ + * The following property can be used to specify a different root path. + * If set to false, this is disabled. + */ + // httpAdminRoot: '/admin', + + /** + * The following property can be used to add a custom middleware function + * in front of all admin http routes. For example, to set custom http + * headers. It can be a single function or an array of middleware functions. + */ + // httpAdminMiddleware: function(req,res,next) { + // // Set the X-Frame-Options header to limit where the editor + // // can be embedded + // //res.set('X-Frame-Options', 'sameorigin'); + // next(); + // }, + + + /** + * Some nodes, such as HTTP In, can be used to listen for incoming http requests. + * By default, these are served relative to '/'. The following property + * can be used to specifiy a different root path. If set to false, this is + * disabled. + */ + // httpNodeRoot: '/red-nodes', + + /** + * The following property can be used to configure cross-origin resource sharing + * in the HTTP nodes. + * See https://github.com/troygoode/node-cors#configuration-options for + * details on its contents. The following is a basic permissive set of options: + */ + // httpNodeCors: { + // origin: "*", + // methods: "GET,PUT,POST,DELETE" + // }, + + /** + * If you need to set an http proxy please set an environment variable + * called http_proxy (or HTTP_PROXY) outside of Node-RED in the operating system. + * For example - http_proxy=http://myproxy.com:8080 + * (Setting it here will have no effect) + * You may also specify no_proxy (or NO_PROXY) to supply a comma separated + * list of domains to not proxy, eg - no_proxy=.acme.co,.acme.co.uk. + */ + + /** + * The following property can be used to add a custom middleware function + * in front of all http in nodes. This allows custom authentication to be + * applied to all http in nodes, or any other sort of common request processing. + * It can be a single function or an array of middleware functions. + */ + // httpNodeMiddleware: function(req,res,next) { + // // Handle/reject the request, or pass it on to the http in node by calling next(); + // // Optionally skip our rawBodyParser by setting this to true; + // //req.skipRawBodyParser = true; + // next(); + // }, + + /** + * When httpAdminRoot is used to move the UI to a different root path, the + * following property can be used to identify a directory of static content + * that should be served at http://localhost:1880/. + * When httpStaticRoot is set differently to httpAdminRoot, there is no need + * to move httpAdminRoot. + */ + // httpStatic: '/home/nol/node-red-static/', //single static source + /* OR multiple static sources can be created using an array of objects... */ + // httpStatic: [ + // {path: '/home/nol/pics/', root: "/img/"}, + // {path: '/home/nol/reports/', root: "/doc/"}, + // ], + + /** + * All static routes will be appended to httpStaticRoot + * e.g. If httpStatic = "/home/nol/docs" and httpStaticRoot = "/static/" + * then "/home/nol/docs" will be served at "/static/" + * e.g. if httpStatic = [{path: '/home/nol/pics/', root: "/img/"}] + * and httpStaticRoot = "/static/" + * then "/home/nol/pics/" will be served at "/static/img/". + */ + // httpStaticRoot: '/static/', + +/** + * *****************************************************************************. + * Runtime Settings + * - lang + * - runtimeState + * - diagnostics + * - logging + * - contextStorage + * - exportGlobalContextKeys + * - externalModules + *****************************************************************************. + */ + + /** + * Uncomment the following to run node-red in your preferred language. + * Available languages include: en-US (default), ja, de, zh-CN, zh-TW, ru, ko + * Some languages are more complete than others. + */ + // lang: "de", + + /** + * Configure diagnostics options + * - enabled: When `enabled` is `true` (or unset), diagnostics data will + * be available at http://localhost:1880/diagnostics + * - ui: When `ui` is `true` (or unset), the action `show-system-info` will + * be available to logged in users of node-red editor. + */ + diagnostics: { + /** Enable or disable diagnostics endpoint. Must be set to `false` to disable */ + enabled: true, + /** Enable or disable diagnostics display in the node-red editor. Must be set to `false` to disable */ + ui: true, + }, + /** + * Configure runtimeState options + * - enabled: When `enabled` is `true` flows runtime can be Started/Stoped + * by POSTing to available at http://localhost:1880/flows/state + * - ui: When `ui` is `true`, the action `core:start-flows` and + * `core:stop-flows` will be available to logged in users of node-red editor + * Also, the deploy menu (when set to default) will show a stop or start button. + */ + runtimeState: { + /** Enable or disable flows/state endpoint. Must be set to `false` to disable */ + enabled: false, + /** Show or hide runtime stop/start options in the node-red editor. Must be set to `false` to hide */ + ui: false, + }, + /** Configure the logging output */ + logging: { + /** Only console logging is currently supported */ + console: { + /** + * Level of logging to be recorded. Options are: + * fatal - only those errors which make the application unusable should be recorded + * error - record errors which are deemed fatal for a particular request + fatal errors + * warn - record problems which are non fatal + errors + fatal errors + * info - record information about the general running of the application + warn + error + fatal errors + * debug - record information which is more verbose than info + info + warn + error + fatal errors + * trace - record very detailed logging + debug + info + warn + error + fatal errors + * off - turn off all logging (doesn't affect metrics or audit). + */ + level: 'info', + /** Whether or not to include metric events in the log output */ + metrics: false, + /** Whether or not to include audit events in the log output */ + audit: false + } + }, + + /** + * Context Storage + * The following property can be used to enable context storage. The configuration + * provided here will enable file-based context that flushes to disk every 30 seconds. + * Refer to the documentation for further options: https://nodered.org/docs/api/context/. + */ + // contextStorage: { + // default: { + // module:"localfilesystem" + // }, + // }, + + /** + * `global.keys()` returns a list of all properties set in global context. + * This allows them to be displayed in the Context Sidebar within the editor. + * In some circumstances it is not desirable to expose them to the editor. The + * following property can be used to hide any property set in `functionGlobalContext` + * from being list by `global.keys()`. + * By default, the property is set to false to avoid accidental exposure of + * their values. Setting this to true will cause the keys to be listed. + */ + exportGlobalContextKeys: false, + + /** + * Configure how the runtime will handle external npm modules. + * This covers: + * - whether the editor will allow new node modules to be installed + * - whether nodes, such as the Function node are allowed to have their + * own dynamically configured dependencies. + * The allow/denyList options can be used to limit what modules the runtime + * will install/load. It can use '*' as a wildcard that matches anything. + */ + externalModules: { + // autoInstall: false, /** Whether the runtime will attempt to automatically install missing modules */ + // autoInstallRetry: 30, /** Interval, in seconds, between reinstall attempts */ + // palette: { /** Configuration for the Palette Manager */ + // allowInstall: true, /** Enable the Palette Manager in the editor */ + // allowUpdate: true, /** Allow modules to be updated in the Palette Manager */ + // allowUpload: true, /** Allow module tgz files to be uploaded and installed */ + // allowList: ['*'], + // denyList: [], + // allowUpdateList: ['*'], + // denyUpdateList: [] + // }, + // modules: { /** Configuration for node-specified modules */ + // allowInstall: true, + // allowList: [], + // denyList: [] + // } + }, + + +/** + * *****************************************************************************. + * Editor Settings + * - disableEditor + * - editorTheme + *****************************************************************************. + */ + + /** + * The following property can be used to disable the editor. The admin API + * is not affected by this option. To disable both the editor and the admin + * API, use either the httpRoot or httpAdminRoot properties. + */ + // disableEditor: false, + + /** + * Customising the editor + * See https://nodered.org/docs/user-guide/runtime/configuration#editor-themes + * for all available options. + */ + editorTheme: { + /** + * The following property can be used to set a custom theme for the editor. + * See https://github.com/node-red-contrib-themes/theme-collection for + * a collection of themes to chose from. + */ + // theme: "", + + /** + * To disable the 'Welcome to Node-RED' tour that is displayed the first + * time you access the editor for each release of Node-RED, set this to false. + */ + // tours: false, + + palette: { + /** + * The following property can be used to order the categories in the editor + * palette. If a node's category is not in the list, the category will get + * added to the end of the palette. + * If not set, the following default order is used: + */ + // categories: ['subflows', 'common', 'function', 'network', 'sequence', 'parser', 'storage'], + }, + + projects: { + /** To enable the Projects feature, set this value to true */ + enabled: false, + workflow: { + /** + * Set the default projects workflow mode. + * - manual - you must manually commit changes + * - auto - changes are automatically committed + * This can be overridden per-user from the 'Git config' + * section of 'User Settings' within the editor. + */ + mode: 'manual' + } + }, + + codeEditor: { + /** + * Select the text editor component used by the editor. + * As of Node-RED V3, this defaults to "monaco", but can be set to "ace" if desired. + */ + lib: 'monaco', + options: { + /** + * The follow options only apply if the editor is set to "monaco". + * + * Theme - must match the file name of a theme in + * packages/node_modules/@node-red/editor-client/src/vendor/monaco/dist/theme + * e.g. "tomorrow-night", "upstream-sunburst", "github", "my-theme". + */ + // theme: "vs", + /** + * Other overrides can be set e.g. FontSize, fontFamily, fontLigatures etc. + * For the full list, see https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html. + */ + // fontSize: 14, + // fontFamily: "Cascadia Code, Fira Code, Consolas, 'Courier New', monospace", + // fontLigatures: true, + } + } + }, + +/** + * *****************************************************************************. + * Node Settings + * - fileWorkingDirectory + * - functionGlobalContext + * - functionExternalModules + * - nodeMessageBufferMaxLength + * - ui (for use with Node-RED Dashboard) + * - debugUseColors + * - debugMaxLength + * - execMaxBufferSize + * - httpRequestTimeout + * - mqttReconnectTime + * - serialReconnectTime + * - socketReconnectTime + * - socketTimeout + * - tcpMsgQueueSize + * - inboundWebSocketTimeout + * - tlsConfigDisableLocalFiles + * - webSocketNodeVerifyClient + *****************************************************************************. + */ + + /** + * The working directory to handle relative file paths from within the File nodes + * defaults to the working directory of the Node-RED process. + */ + // fileWorkingDirectory: "", + + /** Allow the Function node to load additional npm modules directly */ + functionExternalModules: true, + + /** + * The following property can be used to set predefined values in Global Context. + * This allows extra node modules to be made available with in Function node. + * For example, the following: + * functionGlobalContext: { os:require('os') } + * will allow the `os` module to be accessed in a Function node using: + * global.get("os"). + */ + functionGlobalContext: { + // os:require('os'), + }, + + /** + * The maximum number of messages nodes will buffer internally as part of their + * operation. This applies across a range of nodes that operate on message sequences. + * Defaults to no limit. A value of 0 also means no limit is applied. + */ + // nodeMessageBufferMaxLength: 0, + + /** + * If you installed the optional node-red-dashboard you can set it's path + * relative to httpNodeRoot + * Other optional properties include + * readOnly:{boolean}, + * middleware:{function or array}, (req,res,next) - http middleware + * ioMiddleware:{function or array}, (socket,next) - socket.io middleware. + */ + // ui: { path: "ui" }, + + /** Colourise the console output of the debug node */ + // debugUseColors: true, + + /** The maximum length, in characters, of any message sent to the debug sidebar tab */ + debugMaxLength: 1000, + + /** Maximum buffer size for the exec node. Defaults to 10Mb */ + // execMaxBufferSize: 10000000, + + /** Timeout in milliseconds for HTTP request connections. Defaults to 120s */ + // httpRequestTimeout: 120000, + + /** Retry time in milliseconds for MQTT connections */ + mqttReconnectTime: 15000, + + /** Retry time in milliseconds for Serial port connections */ + serialReconnectTime: 15000, + + /** Retry time in milliseconds for TCP socket connections */ + // socketReconnectTime: 10000, + + /** Timeout in milliseconds for TCP server socket connections. Defaults to no timeout */ + // socketTimeout: 120000, + + /** + * Maximum number of messages to wait in queue while attempting to connect to TCP socket + * defaults to 1000. + */ + // tcpMsgQueueSize: 2000, + + /** + * Timeout in milliseconds for inbound WebSocket connections that do not + * match any configured node. Defaults to 5000. + */ + // inboundWebSocketTimeout: 5000, + + /** + * To disable the option for using local files for storing keys and + * certificates in the TLS configuration node, set this to true. + */ + // tlsConfigDisableLocalFiles: true, + + /** + * The following property can be used to verify websocket connection attempts. + * This allows, for example, the HTTP request headers to be checked to ensure + * they include valid authentication information. + */ + // webSocketNodeVerifyClient: function(info) { + // /** 'info' has three properties: + // * - origin : the value in the Origin header + // * - req : the HTTP request + // * - secure : true if req.connection.authorized or req.connection.encrypted is set + // * + // * The function should return true if the connection should be accepted, false otherwise. + // * + // * Alternatively, if this function is defined to accept a second argument, callback, + // * it can be used to verify the client asynchronously. + // * The callback takes three arguments: + // * - result : boolean, whether to accept the connection or not + // * - code : if result is false, the HTTP error status to return + // * - reason: if result is false, the HTTP reason string to return + // */ + // }, +}; diff --git a/server/services/node-red/index.js b/server/services/node-red/index.js new file mode 100644 index 0000000000..f0aca1bf88 --- /dev/null +++ b/server/services/node-red/index.js @@ -0,0 +1,36 @@ +const logger = require('../../utils/logger'); +const NodeRedManager = require('./lib'); +const NodeRedController = require('./api/node-red.controller'); + +module.exports = function NodeRedService(gladys, serviceId) { + const nodeRedManager = new NodeRedManager(gladys, serviceId); + + /** + * @public + * @description This function starts service. + * @example + * gladys.services['node-red'].start(); + */ + async function start() { + logger.log('Starting Node-RED service'); + await nodeRedManager.init(); + } + + /** + * @public + * @description This function stops the service. + * @example + * gladys.services['node-red'].stop(); + */ + function stop() { + logger.log('Stopping Node-RED service'); + nodeRedManager.disconnect(); + } + + return Object.freeze({ + start, + stop, + device: nodeRedManager, + controllers: NodeRedController(gladys, nodeRedManager), + }); +}; diff --git a/server/services/node-red/lib/checkForContainerUpdates.js b/server/services/node-red/lib/checkForContainerUpdates.js new file mode 100644 index 0000000000..76a5b7c3a5 --- /dev/null +++ b/server/services/node-red/lib/checkForContainerUpdates.js @@ -0,0 +1,40 @@ +const logger = require('../../../utils/logger'); +const { DEFAULT } = require('./constants'); + +const nodeRedContainerDescriptor = require('../docker/gladys-node-red-container.json'); + +/** + * @description Checks if version is the latest for this service, if not, it removes existing containers. + * @param {object} config - Service configuration properties. + * @example + * await nodeRed.checkForContainerUpdates(config); + */ +async function checkForContainerUpdates(config) { + logger.info('Node-RED: Checking for current installed versions and required updates...'); + + // Check for NodeRed container version + if (config.dockerNodeRedVersion !== DEFAULT.DOCKER_NODE_RED_VERSION) { + logger.info(`Node-RED: update #${DEFAULT.DOCKER_NODE_RED_VERSION} of the container required...`); + + const containers = await this.gladys.system.getContainers({ + all: true, + filters: { name: [nodeRedContainerDescriptor.name] }, + }); + + if (containers.length !== 0) { + logger.debug('Node-RED: Removing current installed Node-RED container...'); + // If container is present, we remove it + // The init process will create it again + const [container] = containers; + await this.gladys.system.removeContainer(container.id, { force: true }); + } + + // Update to last version + config.dockerNodeRedVersion = DEFAULT.DOCKER_NODE_RED_VERSION; + logger.info(`Node-RED: update #${DEFAULT.DOCKER_NODE_RED_VERSION} of the container done`); + } +} + +module.exports = { + checkForContainerUpdates, +}; diff --git a/server/services/node-red/lib/configureContainer.js b/server/services/node-red/lib/configureContainer.js new file mode 100644 index 0000000000..09c62f1e48 --- /dev/null +++ b/server/services/node-red/lib/configureContainer.js @@ -0,0 +1,72 @@ +const fs = require('fs/promises'); +const { constants } = require('fs'); +const path = require('path'); +const logger = require('../../../utils/logger'); +const passwordUtils = require('../../../utils/password'); +const { DEFAULT } = require('./constants'); + +/** + * @description Configure Node-RED container. + * @param {object} config - Gladys Node-RED stored configuration. + * @returns {Promise} Indicates if the configuration file has been created or modified. + * @example + * await this.configureContainer({}); + */ +async function configureContainer(config) { + logger.info('Node-RED: Docker container is being configured...'); + + const { basePathOnContainer } = await this.gladys.system.getGladysBasePath(); + + // Create configuration path (if not exists) + const configFilepath = path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH); + await fs.mkdir(path.dirname(configFilepath), { recursive: true }); + try { + await fs.chown(path.dirname(configFilepath), 1000, 1000); + } catch (e) { + logger.error('Node-RED: Unable to change write of the configuration'); + } + + // Check if config file not already exists + let configCreated = false; + try { + // eslint-disable-next-line no-bitwise + await fs.access(configFilepath, constants.R_OK | constants.W_OK); + logger.info('Node-RED: configuration file already exists.'); + } catch (e) { + logger.info('Node-RED: Writing default configuration...'); + await fs.copyFile(path.join(__dirname, '../docker/settings.txt'), configFilepath); + configCreated = true; + } + + const fileContent = await fs.readFile(configFilepath); + let fileContentString = fileContent.toString(); + + let configChanged = false; + if (config.nodeRedPassword && config.nodeRedUsername) { + // Check for changes + const [, username] = fileContentString.match(/username: '(.+)'/); + const [, password] = fileContentString.match(/password: '(.+)'/); + + if ( + username !== config.nodeRedUsername || + (await passwordUtils.compare(config.nodeRedPassword, password)) === false + ) { + const encodedPassword = await passwordUtils.hash(config.nodeRedPassword, 8); + fileContentString = fileContentString.replace(/username: '(.+)'/, `username: '${config.nodeRedUsername}'`); + fileContentString = fileContentString.replace(/password: '(.+)'/, `password: '${encodedPassword}'`); + + configChanged = true; + } + } + + if (configChanged) { + logger.info('Node-RED: Writting configuration...'); + await fs.writeFile(configFilepath, fileContentString); + } + + return configCreated || configChanged; +} + +module.exports = { + configureContainer, +}; diff --git a/server/services/node-red/lib/constants.js b/server/services/node-red/lib/constants.js new file mode 100644 index 0000000000..f1a5f61202 --- /dev/null +++ b/server/services/node-red/lib/constants.js @@ -0,0 +1,20 @@ +const CONFIGURATION = { + NODE_RED_USERNAME_VALUE: 'admin', + NODE_RED_PORT_VALUE: '1881', + + NODE_RED_USERNAME: 'NODE_RED_USERNAME', + NODE_RED_PASSWORD: 'NODE_RED_PASSWORD', + NODE_RED_PORT: 'NODE_RED_PORT', + + DOCKER_NODE_RED_VERSION: 'DOCKER_NODE_RED_VERSION', // Variable to identify last version of NodeRed docker file is installed +}; + +const DEFAULT = { + DOCKER_NODE_RED_VERSION: '2', // Last version of NodeRed docker file, + CONFIGURATION_PATH: 'node-red/settings.js', +}; + +module.exports = { + CONFIGURATION, + DEFAULT, +}; diff --git a/server/services/node-red/lib/disconnect.js b/server/services/node-red/lib/disconnect.js new file mode 100644 index 0000000000..a3f48e6172 --- /dev/null +++ b/server/services/node-red/lib/disconnect.js @@ -0,0 +1,46 @@ +const path = require('path'); +const fs = require('fs/promises'); +const logger = require('../../../utils/logger'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + +const nodeRedContainerDescriptor = require('../docker/gladys-node-red-container.json'); +const { DEFAULT } = require('./constants'); + +/** + * @description Disconnect service from dependent containers. + * @example + * disconnect(); + */ +async function disconnect() { + let container; + + // Stop NodeRed container + try { + const dockerContainer = await this.gladys.system.getContainers({ + all: true, + filters: { name: [nodeRedContainerDescriptor.name] }, + }); + [container] = dockerContainer; + await this.gladys.system.stopContainer(container.id); + await this.gladys.system.removeContainer(container.id); + + const { basePathOnContainer } = await this.gladys.system.getGladysBasePath(); + + const configFilepath = path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH); + + await fs.rm(path.dirname(configFilepath), { recursive: true }); + + this.nodeRedRunning = false; + this.gladysConnected = false; + } catch (e) { + logger.warn(`Node-RED: failed to stop container ${container.id}:`, e); + } + + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); +} + +module.exports = { + disconnect, +}; diff --git a/server/services/node-red/lib/getConfiguration.js b/server/services/node-red/lib/getConfiguration.js new file mode 100644 index 0000000000..9dc96b7e2b --- /dev/null +++ b/server/services/node-red/lib/getConfiguration.js @@ -0,0 +1,35 @@ +const { SYSTEM_VARIABLE_NAMES } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +const { CONFIGURATION } = require('./constants'); + +/** + * @description Get Node-RED configuration. + * @returns {Promise} Current Node-RED network configuration. + * @example + * const config = await nodeRed.getConfiguration(); + */ +async function getConfiguration() { + logger.debug('Node-RED: loading stored configuration...'); + + const nodeRedUsername = await this.gladys.variable.getValue(CONFIGURATION.NODE_RED_USERNAME, this.serviceId); + const nodeRedPassword = await this.gladys.variable.getValue(CONFIGURATION.NODE_RED_PASSWORD, this.serviceId); + + // Load version parameters + const dockerNodeRedVersion = await this.gladys.variable.getValue( + CONFIGURATION.DOCKER_NODE_RED_VERSION, + this.serviceId, + ); + // Gladys params + const timezone = await this.gladys.variable.getValue(SYSTEM_VARIABLE_NAMES.TIMEZONE); + + return { + nodeRedUsername, + nodeRedPassword, + dockerNodeRedVersion, + timezone, + }; +} + +module.exports = { + getConfiguration, +}; diff --git a/server/services/node-red/lib/index.js b/server/services/node-red/lib/index.js new file mode 100644 index 0000000000..39aa26ab32 --- /dev/null +++ b/server/services/node-red/lib/index.js @@ -0,0 +1,42 @@ +const { init } = require('./init'); +const { getConfiguration } = require('./getConfiguration'); +const { saveConfiguration } = require('./saveConfiguration'); +const { installContainer } = require('./installContainer'); +const { checkForContainerUpdates } = require('./checkForContainerUpdates'); +const { disconnect } = require('./disconnect'); +const { isEnabled } = require('./isEnabled'); +const { status } = require('./status'); +const { configureContainer } = require('./configureContainer'); + +/** + * @description Add ability to connect to Node-RED. + * @param {object} gladys - Gladys instance. + * @param {string} serviceId - UUID of the service in DB. + * @example + * const nodeRedManager = new NodeRedManager(gladys, serviceId); + */ +const NodeRedManager = function NodeRedManager(gladys, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + + this.nodeRedExist = false; + this.nodeRedRunning = false; + + this.gladysConnected = false; + this.networkModeValid = true; + this.dockerBased = true; + + this.containerRestartWaitTimeInMs = 5 * 1000; +}; + +NodeRedManager.prototype.init = init; +NodeRedManager.prototype.getConfiguration = getConfiguration; +NodeRedManager.prototype.saveConfiguration = saveConfiguration; +NodeRedManager.prototype.installContainer = installContainer; +NodeRedManager.prototype.checkForContainerUpdates = checkForContainerUpdates; +NodeRedManager.prototype.disconnect = disconnect; +NodeRedManager.prototype.isEnabled = isEnabled; +NodeRedManager.prototype.status = status; +NodeRedManager.prototype.configureContainer = configureContainer; + +module.exports = NodeRedManager; diff --git a/server/services/node-red/lib/init.js b/server/services/node-red/lib/init.js new file mode 100644 index 0000000000..55fd2a00db --- /dev/null +++ b/server/services/node-red/lib/init.js @@ -0,0 +1,50 @@ +const logger = require('../../../utils/logger'); +const { CONFIGURATION } = require('./constants'); +const { generate } = require('../../../utils/password'); +const { PlatformNotCompatible } = require('../../../utils/coreErrors'); + +/** + * @description Prepares service and starts connection with broker if needed. + * @returns {Promise} Resolve when init finished. + * @example + * await nodeRed.init(); + */ +async function init() { + if (!(await this.isEnabled())) { + logger.info('Nodered: is not enabled, skipping...'); + return; + } + + const dockerBased = await this.gladys.system.isDocker(); + if (!dockerBased) { + this.dockerBased = false; + throw new PlatformNotCompatible('SYSTEM_NOT_RUNNING_DOCKER'); + } + + const networkMode = await this.gladys.system.getNetworkMode(); + if (networkMode !== 'host') { + this.networkModeValid = false; + throw new PlatformNotCompatible('DOCKER_BAD_NETWORK'); + } + + // Load stored configuration + const configuration = await this.getConfiguration(); + + if (!configuration.nodeRedPassword) { + configuration.nodeRedUsername = CONFIGURATION.NODE_RED_USERNAME_VALUE; + configuration.nodeRedPassword = generate(20, { + number: true, + lowercase: true, + uppercase: true, + }); + } + await this.saveConfiguration(configuration); + + logger.debug('Node-RED: installing and starting required docker containers...'); + await this.checkForContainerUpdates(configuration); + await this.installContainer(configuration); +} + +module.exports = { + init, +}; diff --git a/server/services/node-red/lib/installContainer.js b/server/services/node-red/lib/installContainer.js new file mode 100644 index 0000000000..a93d46552d --- /dev/null +++ b/server/services/node-red/lib/installContainer.js @@ -0,0 +1,113 @@ +const cloneDeep = require('lodash.clonedeep'); +const { promisify } = require('util'); + +const path = require('path'); +const logger = require('../../../utils/logger'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); + +const containerDescriptor = require('../docker/gladys-node-red-container.json'); +const { PlatformNotCompatible } = require('../../../utils/coreErrors'); +const { DEFAULT } = require('./constants'); + +const sleep = promisify(setTimeout); + +/** + * @description Install and starts Node-RED container. + * @param {object} config - Service configuration properties. + * @example + * await nodeRed.installContainer(config); + */ +async function installContainer(config) { + if (!(await this.isEnabled())) { + logger.info('Nodered: is not enabled, skipping...'); + return; + } + + const dockerBased = await this.gladys.system.isDocker(); + if (!dockerBased) { + this.dockerBased = false; + throw new PlatformNotCompatible('SYSTEM_NOT_RUNNING_DOCKER'); + } + + const networkMode = await this.gladys.system.getNetworkMode(); + if (networkMode !== 'host') { + this.networkModeValid = false; + throw new PlatformNotCompatible('DOCKER_BAD_NETWORK'); + } + + const { basePathOnHost } = await this.gladys.system.getGladysBasePath(); + + let dockerContainers = await this.gladys.system.getContainers({ + all: true, + filters: { name: [containerDescriptor.name] }, + }); + let [container] = dockerContainers; + + if (dockerContainers.length === 0) { + try { + logger.info('Nodered: is being installed as Docker container...'); + logger.info(`Pulling ${containerDescriptor.Image} image...`); + await this.gladys.system.pull(containerDescriptor.Image); + + // Prepare broker env + logger.info(`Nodered: Preparing environment...`); + await this.configureContainer(config); + + logger.info(`Creation of container...`); + const containerDescriptorToMutate = cloneDeep(containerDescriptor); + + const configFilepath = path.join(basePathOnHost, path.dirname(DEFAULT.CONFIGURATION_PATH)); + + containerDescriptorToMutate.HostConfig.Binds = [`${configFilepath}:/data`]; + + const containerLog = await this.gladys.system.createContainer(containerDescriptorToMutate); + logger.trace(containerLog); + + logger.info('Node-RED: successfully installed and configured as Docker container'); + this.nodeRedExist = true; + } catch (e) { + this.nodeRedExist = false; + logger.error('Node-RED: failed to install as Docker container:', e); + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + throw e; + } + } + + const configChanged = await this.configureContainer(config); + + try { + dockerContainers = await this.gladys.system.getContainers({ + all: true, + filters: { name: [containerDescriptor.name] }, + }); + [container] = dockerContainers; + + // Check if we need to restart the container (container is not running / config changed) + if (container.state !== 'running' || configChanged) { + logger.info('Node-RED: container is (re)starting...'); + await this.gladys.system.restartContainer(container.id); + // wait a few seconds for the container to restart + await sleep(this.containerRestartWaitTimeInMs); + } + + logger.info('Node-RED: container successfully started'); + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + this.nodeRedRunning = true; + this.nodeRedExist = true; + } catch (e) { + logger.error('Node-RED: container failed to start:', e); + this.nodeRedRunning = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + throw e; + } +} + +module.exports = { + installContainer, +}; diff --git a/server/services/node-red/lib/isEnabled.js b/server/services/node-red/lib/isEnabled.js new file mode 100644 index 0000000000..11f2f12975 --- /dev/null +++ b/server/services/node-red/lib/isEnabled.js @@ -0,0 +1,17 @@ +/** + * @description Checks if Node-RED is ready to use. + * @returns {boolean} Is the Node-RED environment ready to use? + * @example + * await nodeRed.isEnabled(); + */ +async function isEnabled() { + const nodeRedEnabled = await this.gladys.variable.getValue('NODERED_ENABLED', this.serviceId); + if (nodeRedEnabled === '1') { + return true; + } + return false; +} + +module.exports = { + isEnabled, +}; diff --git a/server/services/node-red/lib/saveConfiguration.js b/server/services/node-red/lib/saveConfiguration.js new file mode 100644 index 0000000000..98b9f689d3 --- /dev/null +++ b/server/services/node-red/lib/saveConfiguration.js @@ -0,0 +1,40 @@ +const logger = require('../../../utils/logger'); +const { CONFIGURATION } = require('./constants'); + +const saveOrDestroy = async (variableManager, key, value, serviceId) => { + if (value === undefined || value === null) { + await variableManager.destroy(key, serviceId); + } else { + await variableManager.setValue(key, value, serviceId); + } +}; + +/** + * @description Save Node-RED configuration. + * @param {object} config - Node-RED service configuration. + * @returns {Promise} Current Node-RED configuration. + * @example + * await nodeRed.saveConfiguration(config); + */ +async function saveConfiguration(config) { + logger.debug('Node-RED: storing configuration...'); + + const keyValueMap = { + [CONFIGURATION.NODE_RED_USERNAME]: config.nodeRedUsername, + [CONFIGURATION.NODE_RED_PASSWORD]: config.nodeRedPassword, + [CONFIGURATION.DOCKER_NODE_RED_VERSION]: config.dockerNodeRedVersion, + [CONFIGURATION.NODE_RED_PORT]: CONFIGURATION.NODE_RED_PORT_VALUE, + }; + + const variableKeys = Object.keys(keyValueMap); + + await Promise.all( + variableKeys.map((key) => saveOrDestroy(this.gladys.variable, key, keyValueMap[key], this.serviceId)), + ); + + logger.debug('Node-RED: configuration stored'); +} + +module.exports = { + saveConfiguration, +}; diff --git a/server/services/node-red/lib/status.js b/server/services/node-red/lib/status.js new file mode 100644 index 0000000000..74d6e6741b --- /dev/null +++ b/server/services/node-red/lib/status.js @@ -0,0 +1,22 @@ +/** + * @description Get Node-RED status. + * @returns {object} Current Node-RED containers and configuration status. + * @example + * status(); + */ +async function status() { + const nodeRedEnabled = await this.isEnabled(); + const nodeRedStatus = { + nodeRedExist: this.nodeRedExist, + nodeRedRunning: this.nodeRedRunning, + nodeRedEnabled, + gladysConnected: this.gladysConnected, + dockerBased: this.dockerBased, + networkModeValid: this.networkModeValid, + }; + return nodeRedStatus; +} + +module.exports = { + status, +}; diff --git a/server/services/node-red/package-lock.json b/server/services/node-red/package-lock.json new file mode 100644 index 0000000000..312887abf3 --- /dev/null +++ b/server/services/node-red/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "gladys-node-red", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "gladys-node-red", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "lodash.clonedeep": "^4.5.0" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + } + }, + "dependencies": { + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + } + } +} diff --git a/server/services/node-red/package.json b/server/services/node-red/package.json new file mode 100644 index 0000000000..bf2188ccfd --- /dev/null +++ b/server/services/node-red/package.json @@ -0,0 +1,18 @@ +{ + "name": "gladys-node-red", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "dependencies": { + "lodash.clonedeep": "^4.5.0" + } +} diff --git a/server/services/zigbee2mqtt/api/zigbee2mqtt.controller.js b/server/services/zigbee2mqtt/api/zigbee2mqtt.controller.js index ae8cf3073f..53c8632a07 100644 --- a/server/services/zigbee2mqtt/api/zigbee2mqtt.controller.js +++ b/server/services/zigbee2mqtt/api/zigbee2mqtt.controller.js @@ -9,7 +9,8 @@ module.exports = function Zigbee2mqttController(gladys, zigbee2mqttManager) { */ async function getDiscoveredDevices(req, res) { logger.debug('Get discovered devices'); - const devices = zigbee2mqttManager.getDiscoveredDevices(); + const { query = {} } = req; + const devices = zigbee2mqttManager.getDiscoveredDevices(query); res.json(devices); } diff --git a/server/services/zigbee2mqtt/lib/getDiscoveredDevices.js b/server/services/zigbee2mqtt/lib/getDiscoveredDevices.js index b0ff497412..36e6579573 100644 --- a/server/services/zigbee2mqtt/lib/getDiscoveredDevices.js +++ b/server/services/zigbee2mqtt/lib/getDiscoveredDevices.js @@ -3,21 +3,27 @@ const { convertDevice } = require('../utils/convertDevice'); /** * @description Get discovered devices. + * @param {object} filters - Filters to apply. * @returns {Array} Array of discovered devices. * @example * getDiscoveredDevices(); */ -function getDiscoveredDevices() { - return ( - Object.values(this.discoveredDevices) - // Convert to Gladys device - .map((d) => convertDevice(d, this.serviceId)) - .map((d) => { - const existingDevice = this.gladys.stateManager.get('deviceByExternalId', d.external_id); - // Merge with existing device. - return mergeDevices(d, existingDevice); - }) - ); +function getDiscoveredDevices(filters = {}) { + let devices = Object.values(this.discoveredDevices) + // Convert to Gladys device + .map((d) => convertDevice(d, this.serviceId)) + .map((d) => { + const existingDevice = this.gladys.stateManager.get('deviceByExternalId', d.external_id); + // Merge with existing device. + return mergeDevices(d, existingDevice); + }); + + const { filter_existing: filterExisting } = filters; + if (`${filterExisting}` === 'true') { + devices = devices.filter((device) => device.id === undefined || device.updatable); + } + + return devices; } module.exports = { diff --git a/server/test/services/node-red/api/node-red.controller.test.js b/server/test/services/node-red/api/node-red.controller.test.js new file mode 100644 index 0000000000..3bbf0a655a --- /dev/null +++ b/server/test/services/node-red/api/node-red.controller.test.js @@ -0,0 +1,71 @@ +const sinon = require('sinon'); + +const { assert, fake } = sinon; +const NodeRedController = require('../../../../services/node-red/api/node-red.controller'); + +const event = { + emit: fake.resolves(null), +}; + +const gladys = { + event, +}; +const NodeRedManager = { + status: fake.returns(true), + init: fake.returns(true), + installContainer: fake.returns(true), + disconnect: fake.returns(true), +}; + +describe('NodeRed API', () => { + let controller; + + beforeEach(() => { + controller = NodeRedController(gladys, NodeRedManager, 'de1dd005-092d-456d-93d1-817c9ace4c67'); + sinon.reset(); + }); + + it('get /api/v1/service/node-red/status', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['get /api/v1/service/node-red/status'].controller(req, res); + assert.calledOnce(NodeRedManager.status); + assert.calledWith(res.json, true); + }); + + it('post /api/v1/service/node-red/connect', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['post /api/v1/service/node-red/connect'].controller(req, res); + assert.calledOnce(NodeRedManager.init); + assert.calledWith(res.json, { success: true }); + }); + + it('post /api/v1/service/node-red/start', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['post /api/v1/service/node-red/start'].controller(req, res); + assert.calledOnce(NodeRedManager.installContainer); + assert.calledWith(res.json, { success: true }); + }); + + it('post /api/v1/service/node-red/disconnect', async () => { + const req = {}; + const res = { + json: fake.returns(null), + }; + + await controller['post /api/v1/service/node-red/disconnect'].controller(req, res); + assert.calledOnce(NodeRedManager.disconnect); + assert.calledWith(res.json, { success: true }); + }); +}); diff --git a/server/test/services/node-red/expectedDefaultContent.txt b/server/test/services/node-red/expectedDefaultContent.txt new file mode 100644 index 0000000000..a62dd14475 --- /dev/null +++ b/server/test/services/node-red/expectedDefaultContent.txt @@ -0,0 +1,597 @@ +/** + * This is the default settings file provided by Node-RED. + * + * It can contain any valid JavaScript code that will get run when Node-RED + * is started. + * + * Lines that start with // are commented out. + * Each entry should be separated from the entries above and below by a comma ','. + * + * For more information about individual settings, refer to the documentation: + * https://nodered.org/docs/user-guide/runtime/configuration. + * + * The settings are split into the following sections: + * - Flow File and User Directory Settings + * - Security + * - Server Settings + * - Runtime Settings + * - Editor Settings + * - Node Settings. + * + */ + +module.exports = { + +/** + * *****************************************************************************. + * Flow File and User Directory Settings + * - flowFile + * - credentialSecret + * - flowFilePretty + * - userDir + * - nodesDir + *****************************************************************************. + */ + + /** The file containing the flows. If not set, defaults to flows_.json */ + flowFile: 'flows.json', + + /** + * By default, credentials are encrypted in storage using a generated key. To + * specify your own secret, set the following property. + * If you want to disable encryption of credentials, set this property to false. + * Note: once you set this property, do not change it - doing so will prevent + * node-red from being able to decrypt your existing credentials and they will be + * lost. + */ + // credentialSecret: "a-secret-key", + + /** + * By default, the flow JSON will be formatted over multiple lines making + * it easier to compare changes when using version control. + * To disable pretty-printing of the JSON set the following property to false. + */ + flowFilePretty: true, + + /** + * By default, all user data is stored in a directory called `.node-red` under + * the user's home directory. To use a different location, the following + * property can be used. + */ + // userDir: '/home/nol/.node-red/', + + /** + * Node-RED scans the `nodes` directory in the userDir to find local node files. + * The following property can be used to specify an additional directory to scan. + */ + // nodesDir: '/home/nol/.node-red/nodes', + +/** + * *****************************************************************************. + * Security + * - adminAuth + * - https + * - httpsRefreshInterval + * - requireHttps + * - httpNodeAuth + * - httpStaticAuth + *****************************************************************************. + */ + + /** + * To password protect the Node-RED editor and admin API, the following + * property can be used. See http://nodered.org/docs/security.html for details. + */ + adminAuth: { + type: 'credentials', + users: [{ + username: 'admin', + password: '$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN.', + permissions: '*' + }] + }, + + /** + * The following property can be used to enable HTTPS + * This property can be either an object, containing both a (private) key + * and a (public) certificate, or a function that returns such an object. + * See http://nodejs.org/api/https.html#https_https_createserver_options_requestlistener + * for details of its contents. + */ + + /** Option 1: static object */ + // https: { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // }, + + /** Option 2: function that returns the HTTP configuration object */ + // https: function() { + // // This function should return the options object, or a Promise + // // that resolves to the options object + // return { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // } + // }, + + /** + * If the `https` setting is a function, the following setting can be used + * to set how often, in hours, the function will be called. That can be used + * to refresh any certificates. + */ + // httpsRefreshInterval : 12, + + /** + * The following property can be used to cause insecure HTTP connections to + * be redirected to HTTPS. + */ + // requireHttps: true, + + /** + * To password protect the node-defined HTTP endpoints (httpNodeRoot), + * including node-red-dashboard, or the static content (httpStatic), the + * following properties can be used. + * The `pass` field is a bcrypt hash of the password. + * See http://nodered.org/docs/security.html#generating-the-password-hash. + */ + // httpNodeAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + // httpStaticAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + +/** + * *****************************************************************************. + * Server Settings + * - uiPort + * - uiHost + * - apiMaxLength + * - httpServerOptions + * - httpAdminRoot + * - httpAdminMiddleware + * - httpNodeRoot + * - httpNodeCors + * - httpNodeMiddleware + * - httpStatic + * - httpStaticRoot + *****************************************************************************. + */ + + /** The tcp port that the Node-RED web server is listening on */ + uiPort: process.env.PORT || 1880, + + /** + * By default, the Node-RED UI accepts connections on all IPv4 interfaces. + * To listen on all IPv6 addresses, set uiHost to "::", + * The following property can be used to listen on a specific interface. For + * example, the following would only allow connections from the local machine. + */ + uiHost: "0.0.0.0", + + /** + * The maximum size of HTTP request that will be accepted by the runtime api. + * Default: 5mb. + */ + // apiMaxLength: '5mb', + + /** + * The following property can be used to pass custom options to the Express.js + * server used by Node-RED. For a full list of available options, refer + * to http://expressjs.com/en/api.html#app.settings.table. + */ + // httpServerOptions: { }, + + /** + * By default, the Node-RED UI is available at http://localhost:1880/ + * The following property can be used to specify a different root path. + * If set to false, this is disabled. + */ + // httpAdminRoot: '/admin', + + /** + * The following property can be used to add a custom middleware function + * in front of all admin http routes. For example, to set custom http + * headers. It can be a single function or an array of middleware functions. + */ + // httpAdminMiddleware: function(req,res,next) { + // // Set the X-Frame-Options header to limit where the editor + // // can be embedded + // //res.set('X-Frame-Options', 'sameorigin'); + // next(); + // }, + + + /** + * Some nodes, such as HTTP In, can be used to listen for incoming http requests. + * By default, these are served relative to '/'. The following property + * can be used to specifiy a different root path. If set to false, this is + * disabled. + */ + // httpNodeRoot: '/red-nodes', + + /** + * The following property can be used to configure cross-origin resource sharing + * in the HTTP nodes. + * See https://github.com/troygoode/node-cors#configuration-options for + * details on its contents. The following is a basic permissive set of options: + */ + // httpNodeCors: { + // origin: "*", + // methods: "GET,PUT,POST,DELETE" + // }, + + /** + * If you need to set an http proxy please set an environment variable + * called http_proxy (or HTTP_PROXY) outside of Node-RED in the operating system. + * For example - http_proxy=http://myproxy.com:8080 + * (Setting it here will have no effect) + * You may also specify no_proxy (or NO_PROXY) to supply a comma separated + * list of domains to not proxy, eg - no_proxy=.acme.co,.acme.co.uk. + */ + + /** + * The following property can be used to add a custom middleware function + * in front of all http in nodes. This allows custom authentication to be + * applied to all http in nodes, or any other sort of common request processing. + * It can be a single function or an array of middleware functions. + */ + // httpNodeMiddleware: function(req,res,next) { + // // Handle/reject the request, or pass it on to the http in node by calling next(); + // // Optionally skip our rawBodyParser by setting this to true; + // //req.skipRawBodyParser = true; + // next(); + // }, + + /** + * When httpAdminRoot is used to move the UI to a different root path, the + * following property can be used to identify a directory of static content + * that should be served at http://localhost:1880/. + * When httpStaticRoot is set differently to httpAdminRoot, there is no need + * to move httpAdminRoot. + */ + // httpStatic: '/home/nol/node-red-static/', //single static source + /* OR multiple static sources can be created using an array of objects... */ + // httpStatic: [ + // {path: '/home/nol/pics/', root: "/img/"}, + // {path: '/home/nol/reports/', root: "/doc/"}, + // ], + + /** + * All static routes will be appended to httpStaticRoot + * e.g. If httpStatic = "/home/nol/docs" and httpStaticRoot = "/static/" + * then "/home/nol/docs" will be served at "/static/" + * e.g. if httpStatic = [{path: '/home/nol/pics/', root: "/img/"}] + * and httpStaticRoot = "/static/" + * then "/home/nol/pics/" will be served at "/static/img/". + */ + // httpStaticRoot: '/static/', + +/** + * *****************************************************************************. + * Runtime Settings + * - lang + * - runtimeState + * - diagnostics + * - logging + * - contextStorage + * - exportGlobalContextKeys + * - externalModules + *****************************************************************************. + */ + + /** + * Uncomment the following to run node-red in your preferred language. + * Available languages include: en-US (default), ja, de, zh-CN, zh-TW, ru, ko + * Some languages are more complete than others. + */ + // lang: "de", + + /** + * Configure diagnostics options + * - enabled: When `enabled` is `true` (or unset), diagnostics data will + * be available at http://localhost:1880/diagnostics + * - ui: When `ui` is `true` (or unset), the action `show-system-info` will + * be available to logged in users of node-red editor. + */ + diagnostics: { + /** Enable or disable diagnostics endpoint. Must be set to `false` to disable */ + enabled: true, + /** Enable or disable diagnostics display in the node-red editor. Must be set to `false` to disable */ + ui: true, + }, + /** + * Configure runtimeState options + * - enabled: When `enabled` is `true` flows runtime can be Started/Stoped + * by POSTing to available at http://localhost:1880/flows/state + * - ui: When `ui` is `true`, the action `core:start-flows` and + * `core:stop-flows` will be available to logged in users of node-red editor + * Also, the deploy menu (when set to default) will show a stop or start button. + */ + runtimeState: { + /** Enable or disable flows/state endpoint. Must be set to `false` to disable */ + enabled: false, + /** Show or hide runtime stop/start options in the node-red editor. Must be set to `false` to hide */ + ui: false, + }, + /** Configure the logging output */ + logging: { + /** Only console logging is currently supported */ + console: { + /** + * Level of logging to be recorded. Options are: + * fatal - only those errors which make the application unusable should be recorded + * error - record errors which are deemed fatal for a particular request + fatal errors + * warn - record problems which are non fatal + errors + fatal errors + * info - record information about the general running of the application + warn + error + fatal errors + * debug - record information which is more verbose than info + info + warn + error + fatal errors + * trace - record very detailed logging + debug + info + warn + error + fatal errors + * off - turn off all logging (doesn't affect metrics or audit). + */ + level: 'info', + /** Whether or not to include metric events in the log output */ + metrics: false, + /** Whether or not to include audit events in the log output */ + audit: false + } + }, + + /** + * Context Storage + * The following property can be used to enable context storage. The configuration + * provided here will enable file-based context that flushes to disk every 30 seconds. + * Refer to the documentation for further options: https://nodered.org/docs/api/context/. + */ + // contextStorage: { + // default: { + // module:"localfilesystem" + // }, + // }, + + /** + * `global.keys()` returns a list of all properties set in global context. + * This allows them to be displayed in the Context Sidebar within the editor. + * In some circumstances it is not desirable to expose them to the editor. The + * following property can be used to hide any property set in `functionGlobalContext` + * from being list by `global.keys()`. + * By default, the property is set to false to avoid accidental exposure of + * their values. Setting this to true will cause the keys to be listed. + */ + exportGlobalContextKeys: false, + + /** + * Configure how the runtime will handle external npm modules. + * This covers: + * - whether the editor will allow new node modules to be installed + * - whether nodes, such as the Function node are allowed to have their + * own dynamically configured dependencies. + * The allow/denyList options can be used to limit what modules the runtime + * will install/load. It can use '*' as a wildcard that matches anything. + */ + externalModules: { + // autoInstall: false, /** Whether the runtime will attempt to automatically install missing modules */ + // autoInstallRetry: 30, /** Interval, in seconds, between reinstall attempts */ + // palette: { /** Configuration for the Palette Manager */ + // allowInstall: true, /** Enable the Palette Manager in the editor */ + // allowUpdate: true, /** Allow modules to be updated in the Palette Manager */ + // allowUpload: true, /** Allow module tgz files to be uploaded and installed */ + // allowList: ['*'], + // denyList: [], + // allowUpdateList: ['*'], + // denyUpdateList: [] + // }, + // modules: { /** Configuration for node-specified modules */ + // allowInstall: true, + // allowList: [], + // denyList: [] + // } + }, + + +/** + * *****************************************************************************. + * Editor Settings + * - disableEditor + * - editorTheme + *****************************************************************************. + */ + + /** + * The following property can be used to disable the editor. The admin API + * is not affected by this option. To disable both the editor and the admin + * API, use either the httpRoot or httpAdminRoot properties. + */ + // disableEditor: false, + + /** + * Customising the editor + * See https://nodered.org/docs/user-guide/runtime/configuration#editor-themes + * for all available options. + */ + editorTheme: { + /** + * The following property can be used to set a custom theme for the editor. + * See https://github.com/node-red-contrib-themes/theme-collection for + * a collection of themes to chose from. + */ + // theme: "", + + /** + * To disable the 'Welcome to Node-RED' tour that is displayed the first + * time you access the editor for each release of Node-RED, set this to false. + */ + // tours: false, + + palette: { + /** + * The following property can be used to order the categories in the editor + * palette. If a node's category is not in the list, the category will get + * added to the end of the palette. + * If not set, the following default order is used: + */ + // categories: ['subflows', 'common', 'function', 'network', 'sequence', 'parser', 'storage'], + }, + + projects: { + /** To enable the Projects feature, set this value to true */ + enabled: false, + workflow: { + /** + * Set the default projects workflow mode. + * - manual - you must manually commit changes + * - auto - changes are automatically committed + * This can be overridden per-user from the 'Git config' + * section of 'User Settings' within the editor. + */ + mode: 'manual' + } + }, + + codeEditor: { + /** + * Select the text editor component used by the editor. + * As of Node-RED V3, this defaults to "monaco", but can be set to "ace" if desired. + */ + lib: 'monaco', + options: { + /** + * The follow options only apply if the editor is set to "monaco". + * + * Theme - must match the file name of a theme in + * packages/node_modules/@node-red/editor-client/src/vendor/monaco/dist/theme + * e.g. "tomorrow-night", "upstream-sunburst", "github", "my-theme". + */ + // theme: "vs", + /** + * Other overrides can be set e.g. FontSize, fontFamily, fontLigatures etc. + * For the full list, see https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html. + */ + // fontSize: 14, + // fontFamily: "Cascadia Code, Fira Code, Consolas, 'Courier New', monospace", + // fontLigatures: true, + } + } + }, + +/** + * *****************************************************************************. + * Node Settings + * - fileWorkingDirectory + * - functionGlobalContext + * - functionExternalModules + * - nodeMessageBufferMaxLength + * - ui (for use with Node-RED Dashboard) + * - debugUseColors + * - debugMaxLength + * - execMaxBufferSize + * - httpRequestTimeout + * - mqttReconnectTime + * - serialReconnectTime + * - socketReconnectTime + * - socketTimeout + * - tcpMsgQueueSize + * - inboundWebSocketTimeout + * - tlsConfigDisableLocalFiles + * - webSocketNodeVerifyClient + *****************************************************************************. + */ + + /** + * The working directory to handle relative file paths from within the File nodes + * defaults to the working directory of the Node-RED process. + */ + // fileWorkingDirectory: "", + + /** Allow the Function node to load additional npm modules directly */ + functionExternalModules: true, + + /** + * The following property can be used to set predefined values in Global Context. + * This allows extra node modules to be made available with in Function node. + * For example, the following: + * functionGlobalContext: { os:require('os') } + * will allow the `os` module to be accessed in a Function node using: + * global.get("os"). + */ + functionGlobalContext: { + // os:require('os'), + }, + + /** + * The maximum number of messages nodes will buffer internally as part of their + * operation. This applies across a range of nodes that operate on message sequences. + * Defaults to no limit. A value of 0 also means no limit is applied. + */ + // nodeMessageBufferMaxLength: 0, + + /** + * If you installed the optional node-red-dashboard you can set it's path + * relative to httpNodeRoot + * Other optional properties include + * readOnly:{boolean}, + * middleware:{function or array}, (req,res,next) - http middleware + * ioMiddleware:{function or array}, (socket,next) - socket.io middleware. + */ + // ui: { path: "ui" }, + + /** Colourise the console output of the debug node */ + // debugUseColors: true, + + /** The maximum length, in characters, of any message sent to the debug sidebar tab */ + debugMaxLength: 1000, + + /** Maximum buffer size for the exec node. Defaults to 10Mb */ + // execMaxBufferSize: 10000000, + + /** Timeout in milliseconds for HTTP request connections. Defaults to 120s */ + // httpRequestTimeout: 120000, + + /** Retry time in milliseconds for MQTT connections */ + mqttReconnectTime: 15000, + + /** Retry time in milliseconds for Serial port connections */ + serialReconnectTime: 15000, + + /** Retry time in milliseconds for TCP socket connections */ + // socketReconnectTime: 10000, + + /** Timeout in milliseconds for TCP server socket connections. Defaults to no timeout */ + // socketTimeout: 120000, + + /** + * Maximum number of messages to wait in queue while attempting to connect to TCP socket + * defaults to 1000. + */ + // tcpMsgQueueSize: 2000, + + /** + * Timeout in milliseconds for inbound WebSocket connections that do not + * match any configured node. Defaults to 5000. + */ + // inboundWebSocketTimeout: 5000, + + /** + * To disable the option for using local files for storing keys and + * certificates in the TLS configuration node, set this to true. + */ + // tlsConfigDisableLocalFiles: true, + + /** + * The following property can be used to verify websocket connection attempts. + * This allows, for example, the HTTP request headers to be checked to ensure + * they include valid authentication information. + */ + // webSocketNodeVerifyClient: function(info) { + // /** 'info' has three properties: + // * - origin : the value in the Origin header + // * - req : the HTTP request + // * - secure : true if req.connection.authorized or req.connection.encrypted is set + // * + // * The function should return true if the connection should be accepted, false otherwise. + // * + // * Alternatively, if this function is defined to accept a second argument, callback, + // * it can be used to verify the client asynchronously. + // * The callback takes three arguments: + // * - result : boolean, whether to accept the connection or not + // * - code : if result is false, the HTTP error status to return + // * - reason: if result is false, the HTTP reason string to return + // */ + // }, +}; diff --git a/server/test/services/node-red/expectedNodeRedContent.txt b/server/test/services/node-red/expectedNodeRedContent.txt new file mode 100644 index 0000000000..d32fb7fa8c --- /dev/null +++ b/server/test/services/node-red/expectedNodeRedContent.txt @@ -0,0 +1,597 @@ +/** + * This is the default settings file provided by Node-RED. + * + * It can contain any valid JavaScript code that will get run when Node-RED + * is started. + * + * Lines that start with // are commented out. + * Each entry should be separated from the entries above and below by a comma ','. + * + * For more information about individual settings, refer to the documentation: + * https://nodered.org/docs/user-guide/runtime/configuration. + * + * The settings are split into the following sections: + * - Flow File and User Directory Settings + * - Security + * - Server Settings + * - Runtime Settings + * - Editor Settings + * - Node Settings. + * + */ + +module.exports = { + +/** + * *****************************************************************************. + * Flow File and User Directory Settings + * - flowFile + * - credentialSecret + * - flowFilePretty + * - userDir + * - nodesDir + *****************************************************************************. + */ + + /** The file containing the flows. If not set, defaults to flows_.json */ + flowFile: 'flows.json', + + /** + * By default, credentials are encrypted in storage using a generated key. To + * specify your own secret, set the following property. + * If you want to disable encryption of credentials, set this property to false. + * Note: once you set this property, do not change it - doing so will prevent + * node-red from being able to decrypt your existing credentials and they will be + * lost. + */ + // credentialSecret: "a-secret-key", + + /** + * By default, the flow JSON will be formatted over multiple lines making + * it easier to compare changes when using version control. + * To disable pretty-printing of the JSON set the following property to false. + */ + flowFilePretty: true, + + /** + * By default, all user data is stored in a directory called `.node-red` under + * the user's home directory. To use a different location, the following + * property can be used. + */ + // userDir: '/home/nol/.node-red/', + + /** + * Node-RED scans the `nodes` directory in the userDir to find local node files. + * The following property can be used to specify an additional directory to scan. + */ + // nodesDir: '/home/nol/.node-red/nodes', + +/** + * *****************************************************************************. + * Security + * - adminAuth + * - https + * - httpsRefreshInterval + * - requireHttps + * - httpNodeAuth + * - httpStaticAuth + *****************************************************************************. + */ + + /** + * To password protect the Node-RED editor and admin API, the following + * property can be used. See http://nodered.org/docs/security.html for details. + */ + adminAuth: { + type: 'credentials', + users: [{ + username: 'username', + password: 'password', + permissions: '*' + }] + }, + + /** + * The following property can be used to enable HTTPS + * This property can be either an object, containing both a (private) key + * and a (public) certificate, or a function that returns such an object. + * See http://nodejs.org/api/https.html#https_https_createserver_options_requestlistener + * for details of its contents. + */ + + /** Option 1: static object */ + // https: { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // }, + + /** Option 2: function that returns the HTTP configuration object */ + // https: function() { + // // This function should return the options object, or a Promise + // // that resolves to the options object + // return { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // } + // }, + + /** + * If the `https` setting is a function, the following setting can be used + * to set how often, in hours, the function will be called. That can be used + * to refresh any certificates. + */ + // httpsRefreshInterval : 12, + + /** + * The following property can be used to cause insecure HTTP connections to + * be redirected to HTTPS. + */ + // requireHttps: true, + + /** + * To password protect the node-defined HTTP endpoints (httpNodeRoot), + * including node-red-dashboard, or the static content (httpStatic), the + * following properties can be used. + * The `pass` field is a bcrypt hash of the password. + * See http://nodered.org/docs/security.html#generating-the-password-hash. + */ + // httpNodeAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + // httpStaticAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + +/** + * *****************************************************************************. + * Server Settings + * - uiPort + * - uiHost + * - apiMaxLength + * - httpServerOptions + * - httpAdminRoot + * - httpAdminMiddleware + * - httpNodeRoot + * - httpNodeCors + * - httpNodeMiddleware + * - httpStatic + * - httpStaticRoot + *****************************************************************************. + */ + + /** The tcp port that the Node-RED web server is listening on */ + uiPort: process.env.PORT || 1880, + + /** + * By default, the Node-RED UI accepts connections on all IPv4 interfaces. + * To listen on all IPv6 addresses, set uiHost to "::", + * The following property can be used to listen on a specific interface. For + * example, the following would only allow connections from the local machine. + */ + uiHost: "0.0.0.0", + + /** + * The maximum size of HTTP request that will be accepted by the runtime api. + * Default: 5mb. + */ + // apiMaxLength: '5mb', + + /** + * The following property can be used to pass custom options to the Express.js + * server used by Node-RED. For a full list of available options, refer + * to http://expressjs.com/en/api.html#app.settings.table. + */ + // httpServerOptions: { }, + + /** + * By default, the Node-RED UI is available at http://localhost:1880/ + * The following property can be used to specify a different root path. + * If set to false, this is disabled. + */ + // httpAdminRoot: '/admin', + + /** + * The following property can be used to add a custom middleware function + * in front of all admin http routes. For example, to set custom http + * headers. It can be a single function or an array of middleware functions. + */ + // httpAdminMiddleware: function(req,res,next) { + // // Set the X-Frame-Options header to limit where the editor + // // can be embedded + // //res.set('X-Frame-Options', 'sameorigin'); + // next(); + // }, + + + /** + * Some nodes, such as HTTP In, can be used to listen for incoming http requests. + * By default, these are served relative to '/'. The following property + * can be used to specifiy a different root path. If set to false, this is + * disabled. + */ + // httpNodeRoot: '/red-nodes', + + /** + * The following property can be used to configure cross-origin resource sharing + * in the HTTP nodes. + * See https://github.com/troygoode/node-cors#configuration-options for + * details on its contents. The following is a basic permissive set of options: + */ + // httpNodeCors: { + // origin: "*", + // methods: "GET,PUT,POST,DELETE" + // }, + + /** + * If you need to set an http proxy please set an environment variable + * called http_proxy (or HTTP_PROXY) outside of Node-RED in the operating system. + * For example - http_proxy=http://myproxy.com:8080 + * (Setting it here will have no effect) + * You may also specify no_proxy (or NO_PROXY) to supply a comma separated + * list of domains to not proxy, eg - no_proxy=.acme.co,.acme.co.uk. + */ + + /** + * The following property can be used to add a custom middleware function + * in front of all http in nodes. This allows custom authentication to be + * applied to all http in nodes, or any other sort of common request processing. + * It can be a single function or an array of middleware functions. + */ + // httpNodeMiddleware: function(req,res,next) { + // // Handle/reject the request, or pass it on to the http in node by calling next(); + // // Optionally skip our rawBodyParser by setting this to true; + // //req.skipRawBodyParser = true; + // next(); + // }, + + /** + * When httpAdminRoot is used to move the UI to a different root path, the + * following property can be used to identify a directory of static content + * that should be served at http://localhost:1880/. + * When httpStaticRoot is set differently to httpAdminRoot, there is no need + * to move httpAdminRoot. + */ + // httpStatic: '/home/nol/node-red-static/', //single static source + /* OR multiple static sources can be created using an array of objects... */ + // httpStatic: [ + // {path: '/home/nol/pics/', root: "/img/"}, + // {path: '/home/nol/reports/', root: "/doc/"}, + // ], + + /** + * All static routes will be appended to httpStaticRoot + * e.g. If httpStatic = "/home/nol/docs" and httpStaticRoot = "/static/" + * then "/home/nol/docs" will be served at "/static/" + * e.g. if httpStatic = [{path: '/home/nol/pics/', root: "/img/"}] + * and httpStaticRoot = "/static/" + * then "/home/nol/pics/" will be served at "/static/img/". + */ + // httpStaticRoot: '/static/', + +/** + * *****************************************************************************. + * Runtime Settings + * - lang + * - runtimeState + * - diagnostics + * - logging + * - contextStorage + * - exportGlobalContextKeys + * - externalModules + *****************************************************************************. + */ + + /** + * Uncomment the following to run node-red in your preferred language. + * Available languages include: en-US (default), ja, de, zh-CN, zh-TW, ru, ko + * Some languages are more complete than others. + */ + // lang: "de", + + /** + * Configure diagnostics options + * - enabled: When `enabled` is `true` (or unset), diagnostics data will + * be available at http://localhost:1880/diagnostics + * - ui: When `ui` is `true` (or unset), the action `show-system-info` will + * be available to logged in users of node-red editor. + */ + diagnostics: { + /** Enable or disable diagnostics endpoint. Must be set to `false` to disable */ + enabled: true, + /** Enable or disable diagnostics display in the node-red editor. Must be set to `false` to disable */ + ui: true, + }, + /** + * Configure runtimeState options + * - enabled: When `enabled` is `true` flows runtime can be Started/Stoped + * by POSTing to available at http://localhost:1880/flows/state + * - ui: When `ui` is `true`, the action `core:start-flows` and + * `core:stop-flows` will be available to logged in users of node-red editor + * Also, the deploy menu (when set to default) will show a stop or start button. + */ + runtimeState: { + /** Enable or disable flows/state endpoint. Must be set to `false` to disable */ + enabled: false, + /** Show or hide runtime stop/start options in the node-red editor. Must be set to `false` to hide */ + ui: false, + }, + /** Configure the logging output */ + logging: { + /** Only console logging is currently supported */ + console: { + /** + * Level of logging to be recorded. Options are: + * fatal - only those errors which make the application unusable should be recorded + * error - record errors which are deemed fatal for a particular request + fatal errors + * warn - record problems which are non fatal + errors + fatal errors + * info - record information about the general running of the application + warn + error + fatal errors + * debug - record information which is more verbose than info + info + warn + error + fatal errors + * trace - record very detailed logging + debug + info + warn + error + fatal errors + * off - turn off all logging (doesn't affect metrics or audit). + */ + level: 'info', + /** Whether or not to include metric events in the log output */ + metrics: false, + /** Whether or not to include audit events in the log output */ + audit: false + } + }, + + /** + * Context Storage + * The following property can be used to enable context storage. The configuration + * provided here will enable file-based context that flushes to disk every 30 seconds. + * Refer to the documentation for further options: https://nodered.org/docs/api/context/. + */ + // contextStorage: { + // default: { + // module:"localfilesystem" + // }, + // }, + + /** + * `global.keys()` returns a list of all properties set in global context. + * This allows them to be displayed in the Context Sidebar within the editor. + * In some circumstances it is not desirable to expose them to the editor. The + * following property can be used to hide any property set in `functionGlobalContext` + * from being list by `global.keys()`. + * By default, the property is set to false to avoid accidental exposure of + * their values. Setting this to true will cause the keys to be listed. + */ + exportGlobalContextKeys: false, + + /** + * Configure how the runtime will handle external npm modules. + * This covers: + * - whether the editor will allow new node modules to be installed + * - whether nodes, such as the Function node are allowed to have their + * own dynamically configured dependencies. + * The allow/denyList options can be used to limit what modules the runtime + * will install/load. It can use '*' as a wildcard that matches anything. + */ + externalModules: { + // autoInstall: false, /** Whether the runtime will attempt to automatically install missing modules */ + // autoInstallRetry: 30, /** Interval, in seconds, between reinstall attempts */ + // palette: { /** Configuration for the Palette Manager */ + // allowInstall: true, /** Enable the Palette Manager in the editor */ + // allowUpdate: true, /** Allow modules to be updated in the Palette Manager */ + // allowUpload: true, /** Allow module tgz files to be uploaded and installed */ + // allowList: ['*'], + // denyList: [], + // allowUpdateList: ['*'], + // denyUpdateList: [] + // }, + // modules: { /** Configuration for node-specified modules */ + // allowInstall: true, + // allowList: [], + // denyList: [] + // } + }, + + +/** + * *****************************************************************************. + * Editor Settings + * - disableEditor + * - editorTheme + *****************************************************************************. + */ + + /** + * The following property can be used to disable the editor. The admin API + * is not affected by this option. To disable both the editor and the admin + * API, use either the httpRoot or httpAdminRoot properties. + */ + // disableEditor: false, + + /** + * Customising the editor + * See https://nodered.org/docs/user-guide/runtime/configuration#editor-themes + * for all available options. + */ + editorTheme: { + /** + * The following property can be used to set a custom theme for the editor. + * See https://github.com/node-red-contrib-themes/theme-collection for + * a collection of themes to chose from. + */ + // theme: "", + + /** + * To disable the 'Welcome to Node-RED' tour that is displayed the first + * time you access the editor for each release of Node-RED, set this to false. + */ + // tours: false, + + palette: { + /** + * The following property can be used to order the categories in the editor + * palette. If a node's category is not in the list, the category will get + * added to the end of the palette. + * If not set, the following default order is used: + */ + // categories: ['subflows', 'common', 'function', 'network', 'sequence', 'parser', 'storage'], + }, + + projects: { + /** To enable the Projects feature, set this value to true */ + enabled: false, + workflow: { + /** + * Set the default projects workflow mode. + * - manual - you must manually commit changes + * - auto - changes are automatically committed + * This can be overridden per-user from the 'Git config' + * section of 'User Settings' within the editor. + */ + mode: 'manual' + } + }, + + codeEditor: { + /** + * Select the text editor component used by the editor. + * As of Node-RED V3, this defaults to "monaco", but can be set to "ace" if desired. + */ + lib: 'monaco', + options: { + /** + * The follow options only apply if the editor is set to "monaco". + * + * Theme - must match the file name of a theme in + * packages/node_modules/@node-red/editor-client/src/vendor/monaco/dist/theme + * e.g. "tomorrow-night", "upstream-sunburst", "github", "my-theme". + */ + // theme: "vs", + /** + * Other overrides can be set e.g. FontSize, fontFamily, fontLigatures etc. + * For the full list, see https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html. + */ + // fontSize: 14, + // fontFamily: "Cascadia Code, Fira Code, Consolas, 'Courier New', monospace", + // fontLigatures: true, + } + } + }, + +/** + * *****************************************************************************. + * Node Settings + * - fileWorkingDirectory + * - functionGlobalContext + * - functionExternalModules + * - nodeMessageBufferMaxLength + * - ui (for use with Node-RED Dashboard) + * - debugUseColors + * - debugMaxLength + * - execMaxBufferSize + * - httpRequestTimeout + * - mqttReconnectTime + * - serialReconnectTime + * - socketReconnectTime + * - socketTimeout + * - tcpMsgQueueSize + * - inboundWebSocketTimeout + * - tlsConfigDisableLocalFiles + * - webSocketNodeVerifyClient + *****************************************************************************. + */ + + /** + * The working directory to handle relative file paths from within the File nodes + * defaults to the working directory of the Node-RED process. + */ + // fileWorkingDirectory: "", + + /** Allow the Function node to load additional npm modules directly */ + functionExternalModules: true, + + /** + * The following property can be used to set predefined values in Global Context. + * This allows extra node modules to be made available with in Function node. + * For example, the following: + * functionGlobalContext: { os:require('os') } + * will allow the `os` module to be accessed in a Function node using: + * global.get("os"). + */ + functionGlobalContext: { + // os:require('os'), + }, + + /** + * The maximum number of messages nodes will buffer internally as part of their + * operation. This applies across a range of nodes that operate on message sequences. + * Defaults to no limit. A value of 0 also means no limit is applied. + */ + // nodeMessageBufferMaxLength: 0, + + /** + * If you installed the optional node-red-dashboard you can set it's path + * relative to httpNodeRoot + * Other optional properties include + * readOnly:{boolean}, + * middleware:{function or array}, (req,res,next) - http middleware + * ioMiddleware:{function or array}, (socket,next) - socket.io middleware. + */ + // ui: { path: "ui" }, + + /** Colourise the console output of the debug node */ + // debugUseColors: true, + + /** The maximum length, in characters, of any message sent to the debug sidebar tab */ + debugMaxLength: 1000, + + /** Maximum buffer size for the exec node. Defaults to 10Mb */ + // execMaxBufferSize: 10000000, + + /** Timeout in milliseconds for HTTP request connections. Defaults to 120s */ + // httpRequestTimeout: 120000, + + /** Retry time in milliseconds for MQTT connections */ + mqttReconnectTime: 15000, + + /** Retry time in milliseconds for Serial port connections */ + serialReconnectTime: 15000, + + /** Retry time in milliseconds for TCP socket connections */ + // socketReconnectTime: 10000, + + /** Timeout in milliseconds for TCP server socket connections. Defaults to no timeout */ + // socketTimeout: 120000, + + /** + * Maximum number of messages to wait in queue while attempting to connect to TCP socket + * defaults to 1000. + */ + // tcpMsgQueueSize: 2000, + + /** + * Timeout in milliseconds for inbound WebSocket connections that do not + * match any configured node. Defaults to 5000. + */ + // inboundWebSocketTimeout: 5000, + + /** + * To disable the option for using local files for storing keys and + * certificates in the TLS configuration node, set this to true. + */ + // tlsConfigDisableLocalFiles: true, + + /** + * The following property can be used to verify websocket connection attempts. + * This allows, for example, the HTTP request headers to be checked to ensure + * they include valid authentication information. + */ + // webSocketNodeVerifyClient: function(info) { + // /** 'info' has three properties: + // * - origin : the value in the Origin header + // * - req : the HTTP request + // * - secure : true if req.connection.authorized or req.connection.encrypted is set + // * + // * The function should return true if the connection should be accepted, false otherwise. + // * + // * Alternatively, if this function is defined to accept a second argument, callback, + // * it can be used to verify the client asynchronously. + // * The callback takes three arguments: + // * - result : boolean, whether to accept the connection or not + // * - code : if result is false, the HTTP error status to return + // * - reason: if result is false, the HTTP reason string to return + // */ + // }, +}; diff --git a/server/test/services/node-red/expectedOtherNodeRedContent.txt b/server/test/services/node-red/expectedOtherNodeRedContent.txt new file mode 100644 index 0000000000..b0bb7facd5 --- /dev/null +++ b/server/test/services/node-red/expectedOtherNodeRedContent.txt @@ -0,0 +1,597 @@ +/** + * This is the default settings file provided by Node-RED. + * + * It can contain any valid JavaScript code that will get run when Node-RED + * is started. + * + * Lines that start with // are commented out. + * Each entry should be separated from the entries above and below by a comma ','. + * + * For more information about individual settings, refer to the documentation: + * https://nodered.org/docs/user-guide/runtime/configuration. + * + * The settings are split into the following sections: + * - Flow File and User Directory Settings + * - Security + * - Server Settings + * - Runtime Settings + * - Editor Settings + * - Node Settings. + * + */ + +module.exports = { + +/** + * *****************************************************************************. + * Flow File and User Directory Settings + * - flowFile + * - credentialSecret + * - flowFilePretty + * - userDir + * - nodesDir + *****************************************************************************. + */ + + /** The file containing the flows. If not set, defaults to flows_.json */ + flowFile: 'flows.json', + + /** + * By default, credentials are encrypted in storage using a generated key. To + * specify your own secret, set the following property. + * If you want to disable encryption of credentials, set this property to false. + * Note: once you set this property, do not change it - doing so will prevent + * node-red from being able to decrypt your existing credentials and they will be + * lost. + */ + // credentialSecret: "a-secret-key", + + /** + * By default, the flow JSON will be formatted over multiple lines making + * it easier to compare changes when using version control. + * To disable pretty-printing of the JSON set the following property to false. + */ + flowFilePretty: true, + + /** + * By default, all user data is stored in a directory called `.node-red` under + * the user's home directory. To use a different location, the following + * property can be used. + */ + // userDir: '/home/nol/.node-red/', + + /** + * Node-RED scans the `nodes` directory in the userDir to find local node files. + * The following property can be used to specify an additional directory to scan. + */ + // nodesDir: '/home/nol/.node-red/nodes', + +/** + * *****************************************************************************. + * Security + * - adminAuth + * - https + * - httpsRefreshInterval + * - requireHttps + * - httpNodeAuth + * - httpStaticAuth + *****************************************************************************. + */ + + /** + * To password protect the Node-RED editor and admin API, the following + * property can be used. See http://nodered.org/docs/security.html for details. + */ + adminAuth: { + type: 'credentials', + users: [{ + username: 'other-username', + password: 'other-password', + permissions: '*' + }] + }, + + /** + * The following property can be used to enable HTTPS + * This property can be either an object, containing both a (private) key + * and a (public) certificate, or a function that returns such an object. + * See http://nodejs.org/api/https.html#https_https_createserver_options_requestlistener + * for details of its contents. + */ + + /** Option 1: static object */ + // https: { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // }, + + /** Option 2: function that returns the HTTP configuration object */ + // https: function() { + // // This function should return the options object, or a Promise + // // that resolves to the options object + // return { + // key: require("fs").readFileSync('privkey.pem'), + // cert: require("fs").readFileSync('cert.pem') + // } + // }, + + /** + * If the `https` setting is a function, the following setting can be used + * to set how often, in hours, the function will be called. That can be used + * to refresh any certificates. + */ + // httpsRefreshInterval : 12, + + /** + * The following property can be used to cause insecure HTTP connections to + * be redirected to HTTPS. + */ + // requireHttps: true, + + /** + * To password protect the node-defined HTTP endpoints (httpNodeRoot), + * including node-red-dashboard, or the static content (httpStatic), the + * following properties can be used. + * The `pass` field is a bcrypt hash of the password. + * See http://nodered.org/docs/security.html#generating-the-password-hash. + */ + // httpNodeAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + // httpStaticAuth: {user:"user",pass:"$2a$08$zZWtXTja0fB1pzD4sHCMyOCMYz2Z6dNbM6tl8sJogENOMcxWV9DN."}, + +/** + * *****************************************************************************. + * Server Settings + * - uiPort + * - uiHost + * - apiMaxLength + * - httpServerOptions + * - httpAdminRoot + * - httpAdminMiddleware + * - httpNodeRoot + * - httpNodeCors + * - httpNodeMiddleware + * - httpStatic + * - httpStaticRoot + *****************************************************************************. + */ + + /** The tcp port that the Node-RED web server is listening on */ + uiPort: process.env.PORT || 1880, + + /** + * By default, the Node-RED UI accepts connections on all IPv4 interfaces. + * To listen on all IPv6 addresses, set uiHost to "::", + * The following property can be used to listen on a specific interface. For + * example, the following would only allow connections from the local machine. + */ + uiHost: "0.0.0.0", + + /** + * The maximum size of HTTP request that will be accepted by the runtime api. + * Default: 5mb. + */ + // apiMaxLength: '5mb', + + /** + * The following property can be used to pass custom options to the Express.js + * server used by Node-RED. For a full list of available options, refer + * to http://expressjs.com/en/api.html#app.settings.table. + */ + // httpServerOptions: { }, + + /** + * By default, the Node-RED UI is available at http://localhost:1880/ + * The following property can be used to specify a different root path. + * If set to false, this is disabled. + */ + // httpAdminRoot: '/admin', + + /** + * The following property can be used to add a custom middleware function + * in front of all admin http routes. For example, to set custom http + * headers. It can be a single function or an array of middleware functions. + */ + // httpAdminMiddleware: function(req,res,next) { + // // Set the X-Frame-Options header to limit where the editor + // // can be embedded + // //res.set('X-Frame-Options', 'sameorigin'); + // next(); + // }, + + + /** + * Some nodes, such as HTTP In, can be used to listen for incoming http requests. + * By default, these are served relative to '/'. The following property + * can be used to specifiy a different root path. If set to false, this is + * disabled. + */ + // httpNodeRoot: '/red-nodes', + + /** + * The following property can be used to configure cross-origin resource sharing + * in the HTTP nodes. + * See https://github.com/troygoode/node-cors#configuration-options for + * details on its contents. The following is a basic permissive set of options: + */ + // httpNodeCors: { + // origin: "*", + // methods: "GET,PUT,POST,DELETE" + // }, + + /** + * If you need to set an http proxy please set an environment variable + * called http_proxy (or HTTP_PROXY) outside of Node-RED in the operating system. + * For example - http_proxy=http://myproxy.com:8080 + * (Setting it here will have no effect) + * You may also specify no_proxy (or NO_PROXY) to supply a comma separated + * list of domains to not proxy, eg - no_proxy=.acme.co,.acme.co.uk. + */ + + /** + * The following property can be used to add a custom middleware function + * in front of all http in nodes. This allows custom authentication to be + * applied to all http in nodes, or any other sort of common request processing. + * It can be a single function or an array of middleware functions. + */ + // httpNodeMiddleware: function(req,res,next) { + // // Handle/reject the request, or pass it on to the http in node by calling next(); + // // Optionally skip our rawBodyParser by setting this to true; + // //req.skipRawBodyParser = true; + // next(); + // }, + + /** + * When httpAdminRoot is used to move the UI to a different root path, the + * following property can be used to identify a directory of static content + * that should be served at http://localhost:1880/. + * When httpStaticRoot is set differently to httpAdminRoot, there is no need + * to move httpAdminRoot. + */ + // httpStatic: '/home/nol/node-red-static/', //single static source + /* OR multiple static sources can be created using an array of objects... */ + // httpStatic: [ + // {path: '/home/nol/pics/', root: "/img/"}, + // {path: '/home/nol/reports/', root: "/doc/"}, + // ], + + /** + * All static routes will be appended to httpStaticRoot + * e.g. If httpStatic = "/home/nol/docs" and httpStaticRoot = "/static/" + * then "/home/nol/docs" will be served at "/static/" + * e.g. if httpStatic = [{path: '/home/nol/pics/', root: "/img/"}] + * and httpStaticRoot = "/static/" + * then "/home/nol/pics/" will be served at "/static/img/". + */ + // httpStaticRoot: '/static/', + +/** + * *****************************************************************************. + * Runtime Settings + * - lang + * - runtimeState + * - diagnostics + * - logging + * - contextStorage + * - exportGlobalContextKeys + * - externalModules + *****************************************************************************. + */ + + /** + * Uncomment the following to run node-red in your preferred language. + * Available languages include: en-US (default), ja, de, zh-CN, zh-TW, ru, ko + * Some languages are more complete than others. + */ + // lang: "de", + + /** + * Configure diagnostics options + * - enabled: When `enabled` is `true` (or unset), diagnostics data will + * be available at http://localhost:1880/diagnostics + * - ui: When `ui` is `true` (or unset), the action `show-system-info` will + * be available to logged in users of node-red editor. + */ + diagnostics: { + /** Enable or disable diagnostics endpoint. Must be set to `false` to disable */ + enabled: true, + /** Enable or disable diagnostics display in the node-red editor. Must be set to `false` to disable */ + ui: true, + }, + /** + * Configure runtimeState options + * - enabled: When `enabled` is `true` flows runtime can be Started/Stoped + * by POSTing to available at http://localhost:1880/flows/state + * - ui: When `ui` is `true`, the action `core:start-flows` and + * `core:stop-flows` will be available to logged in users of node-red editor + * Also, the deploy menu (when set to default) will show a stop or start button. + */ + runtimeState: { + /** Enable or disable flows/state endpoint. Must be set to `false` to disable */ + enabled: false, + /** Show or hide runtime stop/start options in the node-red editor. Must be set to `false` to hide */ + ui: false, + }, + /** Configure the logging output */ + logging: { + /** Only console logging is currently supported */ + console: { + /** + * Level of logging to be recorded. Options are: + * fatal - only those errors which make the application unusable should be recorded + * error - record errors which are deemed fatal for a particular request + fatal errors + * warn - record problems which are non fatal + errors + fatal errors + * info - record information about the general running of the application + warn + error + fatal errors + * debug - record information which is more verbose than info + info + warn + error + fatal errors + * trace - record very detailed logging + debug + info + warn + error + fatal errors + * off - turn off all logging (doesn't affect metrics or audit). + */ + level: 'info', + /** Whether or not to include metric events in the log output */ + metrics: false, + /** Whether or not to include audit events in the log output */ + audit: false + } + }, + + /** + * Context Storage + * The following property can be used to enable context storage. The configuration + * provided here will enable file-based context that flushes to disk every 30 seconds. + * Refer to the documentation for further options: https://nodered.org/docs/api/context/. + */ + // contextStorage: { + // default: { + // module:"localfilesystem" + // }, + // }, + + /** + * `global.keys()` returns a list of all properties set in global context. + * This allows them to be displayed in the Context Sidebar within the editor. + * In some circumstances it is not desirable to expose them to the editor. The + * following property can be used to hide any property set in `functionGlobalContext` + * from being list by `global.keys()`. + * By default, the property is set to false to avoid accidental exposure of + * their values. Setting this to true will cause the keys to be listed. + */ + exportGlobalContextKeys: false, + + /** + * Configure how the runtime will handle external npm modules. + * This covers: + * - whether the editor will allow new node modules to be installed + * - whether nodes, such as the Function node are allowed to have their + * own dynamically configured dependencies. + * The allow/denyList options can be used to limit what modules the runtime + * will install/load. It can use '*' as a wildcard that matches anything. + */ + externalModules: { + // autoInstall: false, /** Whether the runtime will attempt to automatically install missing modules */ + // autoInstallRetry: 30, /** Interval, in seconds, between reinstall attempts */ + // palette: { /** Configuration for the Palette Manager */ + // allowInstall: true, /** Enable the Palette Manager in the editor */ + // allowUpdate: true, /** Allow modules to be updated in the Palette Manager */ + // allowUpload: true, /** Allow module tgz files to be uploaded and installed */ + // allowList: ['*'], + // denyList: [], + // allowUpdateList: ['*'], + // denyUpdateList: [] + // }, + // modules: { /** Configuration for node-specified modules */ + // allowInstall: true, + // allowList: [], + // denyList: [] + // } + }, + + +/** + * *****************************************************************************. + * Editor Settings + * - disableEditor + * - editorTheme + *****************************************************************************. + */ + + /** + * The following property can be used to disable the editor. The admin API + * is not affected by this option. To disable both the editor and the admin + * API, use either the httpRoot or httpAdminRoot properties. + */ + // disableEditor: false, + + /** + * Customising the editor + * See https://nodered.org/docs/user-guide/runtime/configuration#editor-themes + * for all available options. + */ + editorTheme: { + /** + * The following property can be used to set a custom theme for the editor. + * See https://github.com/node-red-contrib-themes/theme-collection for + * a collection of themes to chose from. + */ + // theme: "", + + /** + * To disable the 'Welcome to Node-RED' tour that is displayed the first + * time you access the editor for each release of Node-RED, set this to false. + */ + // tours: false, + + palette: { + /** + * The following property can be used to order the categories in the editor + * palette. If a node's category is not in the list, the category will get + * added to the end of the palette. + * If not set, the following default order is used: + */ + // categories: ['subflows', 'common', 'function', 'network', 'sequence', 'parser', 'storage'], + }, + + projects: { + /** To enable the Projects feature, set this value to true */ + enabled: false, + workflow: { + /** + * Set the default projects workflow mode. + * - manual - you must manually commit changes + * - auto - changes are automatically committed + * This can be overridden per-user from the 'Git config' + * section of 'User Settings' within the editor. + */ + mode: 'manual' + } + }, + + codeEditor: { + /** + * Select the text editor component used by the editor. + * As of Node-RED V3, this defaults to "monaco", but can be set to "ace" if desired. + */ + lib: 'monaco', + options: { + /** + * The follow options only apply if the editor is set to "monaco". + * + * Theme - must match the file name of a theme in + * packages/node_modules/@node-red/editor-client/src/vendor/monaco/dist/theme + * e.g. "tomorrow-night", "upstream-sunburst", "github", "my-theme". + */ + // theme: "vs", + /** + * Other overrides can be set e.g. FontSize, fontFamily, fontLigatures etc. + * For the full list, see https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html. + */ + // fontSize: 14, + // fontFamily: "Cascadia Code, Fira Code, Consolas, 'Courier New', monospace", + // fontLigatures: true, + } + } + }, + +/** + * *****************************************************************************. + * Node Settings + * - fileWorkingDirectory + * - functionGlobalContext + * - functionExternalModules + * - nodeMessageBufferMaxLength + * - ui (for use with Node-RED Dashboard) + * - debugUseColors + * - debugMaxLength + * - execMaxBufferSize + * - httpRequestTimeout + * - mqttReconnectTime + * - serialReconnectTime + * - socketReconnectTime + * - socketTimeout + * - tcpMsgQueueSize + * - inboundWebSocketTimeout + * - tlsConfigDisableLocalFiles + * - webSocketNodeVerifyClient + *****************************************************************************. + */ + + /** + * The working directory to handle relative file paths from within the File nodes + * defaults to the working directory of the Node-RED process. + */ + // fileWorkingDirectory: "", + + /** Allow the Function node to load additional npm modules directly */ + functionExternalModules: true, + + /** + * The following property can be used to set predefined values in Global Context. + * This allows extra node modules to be made available with in Function node. + * For example, the following: + * functionGlobalContext: { os:require('os') } + * will allow the `os` module to be accessed in a Function node using: + * global.get("os"). + */ + functionGlobalContext: { + // os:require('os'), + }, + + /** + * The maximum number of messages nodes will buffer internally as part of their + * operation. This applies across a range of nodes that operate on message sequences. + * Defaults to no limit. A value of 0 also means no limit is applied. + */ + // nodeMessageBufferMaxLength: 0, + + /** + * If you installed the optional node-red-dashboard you can set it's path + * relative to httpNodeRoot + * Other optional properties include + * readOnly:{boolean}, + * middleware:{function or array}, (req,res,next) - http middleware + * ioMiddleware:{function or array}, (socket,next) - socket.io middleware. + */ + // ui: { path: "ui" }, + + /** Colourise the console output of the debug node */ + // debugUseColors: true, + + /** The maximum length, in characters, of any message sent to the debug sidebar tab */ + debugMaxLength: 1000, + + /** Maximum buffer size for the exec node. Defaults to 10Mb */ + // execMaxBufferSize: 10000000, + + /** Timeout in milliseconds for HTTP request connections. Defaults to 120s */ + // httpRequestTimeout: 120000, + + /** Retry time in milliseconds for MQTT connections */ + mqttReconnectTime: 15000, + + /** Retry time in milliseconds for Serial port connections */ + serialReconnectTime: 15000, + + /** Retry time in milliseconds for TCP socket connections */ + // socketReconnectTime: 10000, + + /** Timeout in milliseconds for TCP server socket connections. Defaults to no timeout */ + // socketTimeout: 120000, + + /** + * Maximum number of messages to wait in queue while attempting to connect to TCP socket + * defaults to 1000. + */ + // tcpMsgQueueSize: 2000, + + /** + * Timeout in milliseconds for inbound WebSocket connections that do not + * match any configured node. Defaults to 5000. + */ + // inboundWebSocketTimeout: 5000, + + /** + * To disable the option for using local files for storing keys and + * certificates in the TLS configuration node, set this to true. + */ + // tlsConfigDisableLocalFiles: true, + + /** + * The following property can be used to verify websocket connection attempts. + * This allows, for example, the HTTP request headers to be checked to ensure + * they include valid authentication information. + */ + // webSocketNodeVerifyClient: function(info) { + // /** 'info' has three properties: + // * - origin : the value in the Origin header + // * - req : the HTTP request + // * - secure : true if req.connection.authorized or req.connection.encrypted is set + // * + // * The function should return true if the connection should be accepted, false otherwise. + // * + // * Alternatively, if this function is defined to accept a second argument, callback, + // * it can be used to verify the client asynchronously. + // * The callback takes three arguments: + // * - result : boolean, whether to accept the connection or not + // * - code : if result is false, the HTTP error status to return + // * - reason: if result is false, the HTTP reason string to return + // */ + // }, +}; diff --git a/server/test/services/node-red/index.test.js b/server/test/services/node-red/index.test.js new file mode 100644 index 0000000000..7f5998b84f --- /dev/null +++ b/server/test/services/node-red/index.test.js @@ -0,0 +1,48 @@ +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const NodeRedService = require('../../../services/node-red'); + +const gladys = { + event: { + emit: fake.returns, + }, + job: { + wrapper: (type, func) => { + return async () => { + return func(); + }; + }, + }, +}; + +describe('node-red service', () => { + // PREPARE + let nodeRedService; + + beforeEach(() => { + nodeRedService = NodeRedService(gladys, 'f87b7af2-ca8e-44fc-b754-444354b42fee'); + nodeRedService.device.init = fake.resolves(null); + nodeRedService.device.disconnect = fake.resolves(null); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should start service', async () => { + // EXECUTE + await nodeRedService.start(); + // ASSERT + assert.calledOnce(nodeRedService.device.init); + assert.notCalled(nodeRedService.device.disconnect); + }); + it('should stop service', async () => { + // EXECUTE + await nodeRedService.stop(); + // ASSERT + assert.calledOnce(nodeRedService.device.disconnect); + assert.notCalled(nodeRedService.device.init); + }); +}); diff --git a/server/test/services/node-red/lib/checkForContainerUpdates.test.js b/server/test/services/node-red/lib/checkForContainerUpdates.test.js new file mode 100644 index 0000000000..ae529c16a9 --- /dev/null +++ b/server/test/services/node-red/lib/checkForContainerUpdates.test.js @@ -0,0 +1,93 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const NodeRedManager = require('../../../../services/node-red/lib'); +const { DEFAULT } = require('../../../../services/node-red/lib/constants'); + +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; + +describe('NodeRed checkForContainerUpdates', () => { + let nodeRedManager; + let gladys; + + beforeEach(() => { + gladys = { + job: { + wrapper: (type, func) => { + return async () => { + return func(); + }; + }, + }, + system: { + getContainers: fake.resolves([]), + removeContainer: fake.resolves(true), + }, + }; + + nodeRedManager = new NodeRedManager(gladys, null, serviceId); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('not updated, but no containers runnning -> it should only update config', async () => { + // PREPARE + const config = { + dockerNodeRedVersion: 'BAD_REVISION', + }; + // EXECUTE + await nodeRedManager.checkForContainerUpdates(config); + // ASSERT + assert.calledWithExactly(gladys.system.getContainers, { + all: true, + filters: { name: ['gladys-node-red'] }, + }); + assert.notCalled(gladys.system.removeContainer); + + expect(config).deep.equal({ + dockerNodeRedVersion: DEFAULT.DOCKER_NODE_RED_VERSION, + }); + }); + + it('not updated, found both containers -> it should remove containers and update config', async () => { + // PREPARE + gladys.system.getContainers = fake.resolves([{ id: 'container-id' }]); + const config = { + dockerNodeRedVersion: 'BAD_REVISION', + }; + // EXECUTE + await nodeRedManager.checkForContainerUpdates(config); + // ASSERT + assert.calledWithExactly(gladys.system.getContainers, { + all: true, + filters: { name: ['gladys-node-red'] }, + }); + + assert.calledWithExactly(gladys.system.removeContainer, 'container-id', { force: true }); + + expect(config).deep.equal({ + dockerNodeRedVersion: DEFAULT.DOCKER_NODE_RED_VERSION, + }); + }); + + it('already updated -> it should do nothing', async () => { + // PREPARE + gladys.system.getContainers = fake.resolves([{ id: 'container-id' }]); + const config = { + dockerNodeRedVersion: DEFAULT.DOCKER_NODE_RED_VERSION, + }; + // EXECUTE + await nodeRedManager.checkForContainerUpdates(config); + // ASSERT + assert.notCalled(gladys.system.getContainers); + assert.notCalled(gladys.system.removeContainer); + + expect(config).deep.equal({ + dockerNodeRedVersion: DEFAULT.DOCKER_NODE_RED_VERSION, + }); + }); +}); diff --git a/server/test/services/node-red/lib/configureContainer.test.js b/server/test/services/node-red/lib/configureContainer.test.js new file mode 100644 index 0000000000..ae7bccb668 --- /dev/null +++ b/server/test/services/node-red/lib/configureContainer.test.js @@ -0,0 +1,113 @@ +const { expect } = require('chai'); +const path = require('path'); +const fs = require('fs'); + +const proxyquire = require('proxyquire').noCallThru(); + +const { fake } = require('sinon'); + +const mockPassword = require('../mockPassword'); + +const configureContainerNodeRed = proxyquire('../../../../services/node-red/lib/configureContainer', { + '../../../utils/password': mockPassword, +}); + +const NodeRedManager = proxyquire('../../../../services/node-red/lib', { + './configureContainer': configureContainerNodeRed, +}); + +const { DEFAULT } = require('../../../../services/node-red/lib/constants'); + +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; +const basePathOnContainer = path.join(__dirname, 'container'); + +describe('NodeRed configureContainer', () => { + let nodeRedManager; + let gladys; + + beforeEach(() => { + gladys = { + job: { + wrapper: (type, func) => { + return async () => { + return func(); + }; + }, + }, + system: { + getGladysBasePath: fake.resolves({ basePathOnContainer }), + }, + }; + + nodeRedManager = new NodeRedManager(gladys, null, serviceId); + }); + + afterEach(() => { + fs.rmSync(basePathOnContainer, { force: true, recursive: true }); + }); + + it('it should write default file', async () => { + const config = { + key: 'value', + }; + await nodeRedManager.configureContainer(config); + // Check that file has been created with defaults + const content = fs.readFileSync(path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH), 'utf8'); + expect(content.toString()).to.equal( + fs.readFileSync(path.join(__dirname, '../expectedDefaultContent.txt'), 'utf8').toString(), + ); + }); + + it('it should not override existing configuration file', async () => { + // Create directory + const configFilepath = path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH); + fs.mkdirSync(path.dirname(configFilepath), { recursive: true }); + // Create custom config file + const customConfigContent = 'content: custom'; + fs.writeFileSync(path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH), customConfigContent); + const config = { + key: 'value', + }; + + await nodeRedManager.configureContainer(config); + + const content = fs.readFileSync(path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH), 'utf8'); + expect(content.toString()).to.equal(customConfigContent); + }); + + it('it should only add credentials', async () => { + // Create directory + const configFilepath = path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH); + fs.mkdirSync(path.dirname(configFilepath), { recursive: true }); + // Create custom config file + const config = { + nodeRedUsername: 'username', + nodeRedPassword: 'password', + }; + + await nodeRedManager.configureContainer(config); + + const content = fs.readFileSync(path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH), 'utf8'); + expect(content.toString()).to.equal( + fs.readFileSync(path.join(__dirname, '../expectedNodeRedContent.txt'), 'utf8').toString(), + ); + }); + + it('it should override credentials', async () => { + // Create directory + const configFilepath = path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH); + fs.mkdirSync(path.dirname(configFilepath), { recursive: true }); + // Create custom config file + const config = { + nodeRedUsername: 'other-username', + nodeRedPassword: 'other-password', + }; + + await nodeRedManager.configureContainer(config); + + const content = fs.readFileSync(path.join(basePathOnContainer, DEFAULT.CONFIGURATION_PATH), 'utf8'); + expect(content.toString()).to.equal( + fs.readFileSync(path.join(__dirname, '../expectedOtherNodeRedContent.txt'), 'utf8').toString(), + ); + }); +}); diff --git a/server/test/services/node-red/lib/disconnect.test.js b/server/test/services/node-red/lib/disconnect.test.js new file mode 100644 index 0000000000..b7d94a46cb --- /dev/null +++ b/server/test/services/node-red/lib/disconnect.test.js @@ -0,0 +1,111 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxiquire = require('proxyquire').noCallThru(); + +const { assert, fake } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); + +const fsMock = { + rm: fake.resolves(true), +}; + +const disconnect = proxiquire('../../../../services/node-red/lib/disconnect', { + 'fs/promises': fsMock, +}); + +const NodeRedManager = proxiquire('../../../../services/node-red/lib', { + './disconnect': disconnect, +}); + +const container = { + id: 'docker-test', +}; + +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; + +const mqtt = { + end: fake.resolves(true), + removeAllListeners: fake.resolves(true), +}; + +const TEMP_GLADYS_FOLDER = process.env.TEMP_FOLDER || '../.tmp'; + +describe('NodeRed disconnect', () => { + let nodeRedManager; + let gladys; + + beforeEach(() => { + gladys = { + event: { + emit: fake.resolves(null), + }, + system: { + getContainers: fake.resolves([container]), + stopContainer: fake.resolves(true), + removeContainer: fake.resolves(true), + getGladysBasePath: fake.resolves({ + basePathOnHost: TEMP_GLADYS_FOLDER, + basePathOnContainer: TEMP_GLADYS_FOLDER, + }), + }, + }; + + nodeRedManager = new NodeRedManager(gladys, mqtt, serviceId); + nodeRedManager.gladysConnected = true; + nodeRedManager.nodeRedRunning = true; + nodeRedManager.nodeRedExist = true; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('stop container', async () => { + await nodeRedManager.disconnect(); + + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + assert.called(gladys.event.emit); + assert.called(gladys.system.stopContainer); + assert.called(gladys.system.removeContainer); + + expect(nodeRedManager.gladysConnected).to.equal(false); + expect(nodeRedManager.nodeRedRunning).to.equal(false); + expect(nodeRedManager.nodeRedExist).to.equal(true); + }); + + it('stop container failed', async () => { + gladys.system.stopContainer = fake.rejects('Error'); + + await nodeRedManager.disconnect(); + + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + assert.called(gladys.event.emit); + assert.called(gladys.system.stopContainer); + + expect(nodeRedManager.gladysConnected).to.equal(true); + expect(nodeRedManager.nodeRedRunning).to.equal(true); + expect(nodeRedManager.nodeRedExist).to.equal(true); + }); + + it('remove container failed', async () => { + gladys.system.removeContainer = fake.rejects('Error'); + + await nodeRedManager.disconnect(); + + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + assert.called(gladys.event.emit); + assert.called(gladys.system.stopContainer); + assert.called(gladys.system.removeContainer); + + expect(nodeRedManager.gladysConnected).to.equal(true); + expect(nodeRedManager.nodeRedRunning).to.equal(true); + expect(nodeRedManager.nodeRedExist).to.equal(true); + }); +}); diff --git a/server/test/services/node-red/lib/getConfiguration.test.js b/server/test/services/node-red/lib/getConfiguration.test.js new file mode 100644 index 0000000000..95d3a0fee4 --- /dev/null +++ b/server/test/services/node-red/lib/getConfiguration.test.js @@ -0,0 +1,44 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { fake, assert } = require('sinon'); + +const NodeRedManager = require('../../../../services/node-red/lib'); + +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; + +describe('NodeRed getConfiguration', () => { + // PREPARE + let nodeRedManager; + let gladys; + + beforeEach(() => { + gladys = { + variable: { + getValue: fake.resolves('fake'), + }, + }; + + nodeRedManager = new NodeRedManager(gladys, serviceId); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load stored configuration', async () => { + const result = await nodeRedManager.getConfiguration(); + + assert.callCount(gladys.variable.getValue, 4); + assert.calledWithExactly(gladys.variable.getValue, 'NODE_RED_USERNAME', serviceId); + assert.calledWithExactly(gladys.variable.getValue, 'NODE_RED_PASSWORD', serviceId); + assert.calledWithExactly(gladys.variable.getValue, 'DOCKER_NODE_RED_VERSION', serviceId); + assert.calledWithExactly(gladys.variable.getValue, 'TIMEZONE'); + + expect(result).to.deep.equal({ + dockerNodeRedVersion: 'fake', + nodeRedPassword: 'fake', + nodeRedUsername: 'fake', + timezone: 'fake', + }); + }); +}); diff --git a/server/test/services/node-red/lib/init.test.js b/server/test/services/node-red/lib/init.test.js new file mode 100644 index 0000000000..1a31473689 --- /dev/null +++ b/server/test/services/node-red/lib/init.test.js @@ -0,0 +1,134 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { assert, fake } = sinon; +const proxyquire = require('proxyquire').noCallThru(); + +const mockPassword = require('../mockPassword'); + +const initNodeRed = proxyquire('../../../../services/node-red/lib/init', { + '../../../utils/password': mockPassword, +}); + +const NodeRedManager = proxyquire('../../../../services/node-red/lib', { + './init': initNodeRed, +}); + +const container = { + id: 'docker-test', +}; + +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; + +describe('NodeRed init', () => { + let nodeRedManager; + let gladys; + + beforeEach(() => { + gladys = { + system: { + getContainers: fake.resolves([container]), + stopContainer: fake.resolves(true), + isDocker: fake.resolves(true), + getNetworkMode: fake.resolves('host'), + restartContainer: fake.resolves(true), + }, + variable: { + getValue: fake.resolves('1'), + }, + }; + + nodeRedManager = new NodeRedManager(gladys, {}, serviceId); + + nodeRedManager.getConfiguration = sinon.stub(); + nodeRedManager.saveConfiguration = sinon.stub(); + nodeRedManager.checkForContainerUpdates = sinon.stub(); + nodeRedManager.installContainer = sinon.stub(); + + nodeRedManager.dockerBased = undefined; + nodeRedManager.networkModeValid = undefined; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('it should fail because not a Docker System', async () => { + gladys.system.isDocker = fake.resolves(false); + + try { + await nodeRedManager.init(); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('SYSTEM_NOT_RUNNING_DOCKER'); + } + + expect(nodeRedManager.dockerBased).to.equal(false); + assert.notCalled(nodeRedManager.getConfiguration); + assert.notCalled(nodeRedManager.saveConfiguration); + assert.notCalled(nodeRedManager.checkForContainerUpdates); + assert.notCalled(nodeRedManager.installContainer); + }); + + it('it should fail because not a host network', async () => { + gladys.system.getNetworkMode = fake.resolves('container'); + + try { + await nodeRedManager.init(); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('DOCKER_BAD_NETWORK'); + } + + expect(nodeRedManager.networkModeValid).to.equal(false); + assert.notCalled(nodeRedManager.getConfiguration); + assert.notCalled(nodeRedManager.saveConfiguration); + assert.notCalled(nodeRedManager.checkForContainerUpdates); + assert.notCalled(nodeRedManager.installContainer); + }); + + it('it should install containers', async () => { + const config = { + nodeRedPassword: 'nodeRedPassword', + }; + nodeRedManager.getConfiguration.resolves({ ...config }); + + await nodeRedManager.init(); + + assert.calledOnceWithExactly(nodeRedManager.getConfiguration); + assert.calledOnceWithExactly(nodeRedManager.saveConfiguration, config); + assert.calledOnceWithExactly(nodeRedManager.checkForContainerUpdates, config); + assert.calledOnceWithExactly(nodeRedManager.installContainer, config); + }); + + it('it should save node-red params', async () => { + const config = {}; + nodeRedManager.getConfiguration.resolves({ ...config }); + + await nodeRedManager.init(); + + const expectedNewConfig = { + nodeRedPassword: 'password', + nodeRedUsername: 'admin', + }; + + assert.calledOnceWithExactly(nodeRedManager.getConfiguration); + assert.calledOnce(nodeRedManager.saveConfiguration); + assert.calledWithMatch(nodeRedManager.saveConfiguration, sinon.match(expectedNewConfig)); + assert.calledOnce(nodeRedManager.checkForContainerUpdates); + assert.calledWithMatch(nodeRedManager.checkForContainerUpdates, sinon.match(expectedNewConfig)); + assert.calledOnce(nodeRedManager.installContainer); + assert.calledWithMatch(nodeRedManager.installContainer, sinon.match(expectedNewConfig)); + }); + + it('should not init if the service is not enabled', async () => { + gladys.variable.getValue = fake.resolves('0'); + + await nodeRedManager.init(); + + assert.notCalled(nodeRedManager.getConfiguration); + assert.notCalled(nodeRedManager.saveConfiguration); + assert.notCalled(nodeRedManager.checkForContainerUpdates); + assert.notCalled(nodeRedManager.installContainer); + }); +}); diff --git a/server/test/services/node-red/lib/installContainer.test.js b/server/test/services/node-red/lib/installContainer.test.js new file mode 100644 index 0000000000..7c8c82eaa8 --- /dev/null +++ b/server/test/services/node-red/lib/installContainer.test.js @@ -0,0 +1,215 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const NodeRedManager = require('../../../../services/node-red/lib'); + +const container = { + id: 'docker-test', + state: 'running', +}; + +const config = {}; + +const containerStopped = { + id: 'docker-test', + state: 'stopped', +}; + +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; + +describe('NodeRed installContainer', () => { + const TEMP_GLADYS_FOLDER = process.env.TEMP_FOLDER || '../.tmp'; + // PREPARE + let nodeRedManager; + let gladys; + + beforeEach(() => { + gladys = { + event: { + emit: fake.resolves(null), + }, + system: { + isDocker: fake.resolves(true), + getNetworkMode: fake.resolves('host'), + getContainers: fake.resolves([containerStopped]), + stopContainer: fake.resolves(true), + pull: fake.resolves(true), + restartContainer: fake.resolves(true), + createContainer: fake.resolves(true), + exec: fake.resolves(true), + getGladysBasePath: fake.resolves({ + basePathOnHost: TEMP_GLADYS_FOLDER, + basePathOnContainer: TEMP_GLADYS_FOLDER, + }), + }, + variable: { + getValue: fake.resolves('1'), + }, + }; + + nodeRedManager = new NodeRedManager(gladys, serviceId); + nodeRedManager.nodeRedRunning = false; + nodeRedManager.nodeRedExist = false; + nodeRedManager.containerRestartWaitTimeInMs = 0; + nodeRedManager.configureContainer = fake.resolves(false); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should create container', async function Test() { + nodeRedManager.configureContainer = fake.resolves(true); + const getContainers = sinon.stub(); + getContainers.onCall(0).resolves([]); + getContainers.onCall(1).resolves([container]); + + gladys.system.getContainers = getContainers; + this.timeout(6000); + + await nodeRedManager.installContainer(config); + + assert.calledWith(gladys.system.pull, 'nodered/node-red:3.1'); + assert.calledWith(gladys.system.createContainer, { + AttachStderr: false, + AttachStdin: false, + AttachStdout: false, + ExposedPorts: { '1880/tcp': {} }, + HostConfig: { + Binds: ['../.tmp/node-red:/data'], + BlkioWeightDevice: [], + Devices: [], + Dns: [], + DnsOptions: [], + DnsSearch: [], + LogConfig: { Config: { 'max-size': '10m' }, Type: 'json-file' }, + PortBindings: { '1880/tcp': [{ HostPort: '1881' }] }, + RestartPolicy: { Name: 'always' }, + }, + Image: 'nodered/node-red:3.1', + NetworkDisabled: false, + Tty: false, + name: 'gladys-node-red', + }); + + assert.calledWith(gladys.system.restartContainer, container.id); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + expect(nodeRedManager.nodeRedRunning).to.equal(true); + expect(nodeRedManager.nodeRedExist).to.equal(true); + }); + + it('should failed when create container failed', async function Test() { + const getContainers = sinon.stub(); + getContainers.onCall(0).resolves([]); + getContainers.onCall(1).resolves([container]); + gladys.system.pull = fake.rejects('Error'); + + gladys.system.getContainers = getContainers; + this.timeout(6000); + + try { + await nodeRedManager.installContainer(); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('Error'); + } + + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + expect(nodeRedManager.nodeRedRunning).to.equal(false); + expect(nodeRedManager.nodeRedExist).to.equal(false); + }); + + it('should restart container', async function Test() { + this.timeout(6000); + + await nodeRedManager.installContainer(config); + + assert.calledWith(gladys.system.restartContainer, container.id); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + expect(nodeRedManager.nodeRedRunning).to.equal(true); + expect(nodeRedManager.nodeRedExist).to.equal(true); + }); + + it('should failed when restart container failed', async function Test() { + this.timeout(6000); + gladys.system.restartContainer = fake.rejects('Error'); + + try { + await nodeRedManager.installContainer(); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('Error'); + } + + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + expect(nodeRedManager.nodeRedExist).to.equal(false); + expect(nodeRedManager.nodeRedRunning).to.equal(false); + }); + + it('should do nothing', async () => { + gladys.system.getContainers = fake.resolves([container]); + + await nodeRedManager.installContainer(config); + + assert.notCalled(gladys.system.restartContainer); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NODERED.STATUS_CHANGE, + }); + expect(nodeRedManager.nodeRedRunning).to.equal(true); + expect(nodeRedManager.nodeRedExist).to.equal(true); + }); + + it('it should fail because not a Docker System', async () => { + gladys.system.isDocker = fake.resolves(false); + + try { + await nodeRedManager.installContainer(); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('SYSTEM_NOT_RUNNING_DOCKER'); + } + + expect(nodeRedManager.dockerBased).to.equal(false); + expect(nodeRedManager.nodeRedRunning).to.equal(false); + expect(nodeRedManager.nodeRedExist).to.equal(false); + }); + + it('it should fail because not a host network', async () => { + gladys.system.getNetworkMode = fake.resolves('container'); + + try { + await nodeRedManager.installContainer(); + assert.fail(); + } catch (e) { + expect(e.message).to.equal('DOCKER_BAD_NETWORK'); + } + + expect(nodeRedManager.networkModeValid).to.equal(false); + expect(nodeRedManager.nodeRedRunning).to.equal(false); + expect(nodeRedManager.nodeRedExist).to.equal(false); + }); + + it('should not create container if the service is not enabled', async () => { + gladys.variable.getValue = fake.resolves('0'); + + await nodeRedManager.installContainer(config); + + assert.notCalled(gladys.system.pull); + assert.notCalled(gladys.system.createContainer); + assert.notCalled(gladys.system.restartContainer); + + expect(nodeRedManager.nodeRedRunning).to.equal(false); + expect(nodeRedManager.nodeRedExist).to.equal(false); + }); +}); diff --git a/server/test/services/node-red/lib/isEnabled.test.js b/server/test/services/node-red/lib/isEnabled.test.js new file mode 100644 index 0000000000..4709d96396 --- /dev/null +++ b/server/test/services/node-red/lib/isEnabled.test.js @@ -0,0 +1,37 @@ +const { expect } = require('chai'); +const { fake } = require('sinon'); + +const NodeRedManager = require('../../../../services/node-red/lib'); + +const gladys = { + variable: { + getValue: fake.resolves(null), + }, +}; +const serviceId = '625a8a9a-aa9d-474f-8cec-0718dd4ade04'; + +describe('NodeRed isEnabled', () => { + let nodeRedService; + beforeEach(() => { + nodeRedService = new NodeRedManager(gladys, serviceId); + }); + + it('should return false when value not exist', async () => { + const result = await nodeRedService.isEnabled(); + expect(result).to.equal(false); + }); + + it('should return false', async () => { + gladys.variable.getValue = fake.resolves('0'); + + const result = await nodeRedService.isEnabled(); + expect(result).to.equal(false); + }); + + it('should return true', async () => { + gladys.variable.getValue = fake.resolves('1'); + + const result = await nodeRedService.isEnabled(); + expect(result).to.equal(true); + }); +}); diff --git a/server/test/services/node-red/lib/saveConfiguration.test.js b/server/test/services/node-red/lib/saveConfiguration.test.js new file mode 100644 index 0000000000..b7e5b5f964 --- /dev/null +++ b/server/test/services/node-red/lib/saveConfiguration.test.js @@ -0,0 +1,58 @@ +const sinon = require('sinon'); +const { fake, assert } = require('sinon'); + +const NodeRedManager = require('../../../../services/node-red/lib'); + +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; + +describe('NodeRed saveConfiguration', () => { + let nodeRedManager; + let gladys; + + beforeEach(() => { + gladys = { + variable: { + setValue: fake.resolves('setValue'), + destroy: fake.resolves('destroy'), + }, + }; + + nodeRedManager = new NodeRedManager(gladys, serviceId); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should store all variables', async () => { + const config = { + nodeRedUsername: 'nodeRedUsername', + nodeRedPassword: 'nodeRedPassword', + dockerNodeRedVersion: 'dockerNodeRedVersion', + }; + + await nodeRedManager.saveConfiguration(config); + + assert.callCount(gladys.variable.setValue, 4); + assert.calledWithExactly(gladys.variable.setValue, 'NODE_RED_USERNAME', config.nodeRedUsername, serviceId); + assert.calledWithExactly(gladys.variable.setValue, 'NODE_RED_PASSWORD', config.nodeRedPassword, serviceId); + assert.calledWithExactly( + gladys.variable.setValue, + 'DOCKER_NODE_RED_VERSION', + config.dockerNodeRedVersion, + serviceId, + ); + assert.calledWithExactly(gladys.variable.setValue, 'NODE_RED_PORT', '1881', serviceId); + }); + + it('should destroy all variables', async () => { + const config = {}; + + await nodeRedManager.saveConfiguration(config); + + assert.callCount(gladys.variable.destroy, 3); + assert.calledWithExactly(gladys.variable.destroy, 'NODE_RED_USERNAME', serviceId); + assert.calledWithExactly(gladys.variable.destroy, 'NODE_RED_PASSWORD', serviceId); + assert.calledWithExactly(gladys.variable.destroy, 'DOCKER_NODE_RED_VERSION', serviceId); + }); +}); diff --git a/server/test/services/node-red/lib/status.test.js b/server/test/services/node-red/lib/status.test.js new file mode 100644 index 0000000000..fd42fc8837 --- /dev/null +++ b/server/test/services/node-red/lib/status.test.js @@ -0,0 +1,38 @@ +const { expect } = require('chai'); +const { fake } = require('sinon'); + +const NodeRedManager = require('../../../../services/node-red/lib'); + +const gladys = { + variable: { + getValue: fake.resolves('1'), + }, +}; +const serviceId = 'f87b7af2-ca8e-44fc-b754-444354b42fee'; + +describe('NodeRed status', () => { + // PREPARE + let nodeRedManager; + + beforeEach(() => { + nodeRedManager = new NodeRedManager(gladys, serviceId); + nodeRedManager.nodeRedExist = true; + nodeRedManager.nodeRedRunning = true; + nodeRedManager.mqttRunning = true; + nodeRedManager.gladysConnected = true; + nodeRedManager.dockerBased = true; + nodeRedManager.networkModeValid = false; + }); + + it('get status', async () => { + // EXECUTE + const result = await nodeRedManager.status(); + // ASSERT + expect(result.nodeRedExist).that.equal(true); + expect(result.nodeRedRunning).that.equal(true); + expect(result.nodeRedEnabled).that.equal(true); + expect(result.gladysConnected).that.equal(true); + expect(result.dockerBased).that.equal(true); + expect(result.networkModeValid).that.equal(false); + }); +}); diff --git a/server/test/services/node-red/mockPassword.js b/server/test/services/node-red/mockPassword.js new file mode 100644 index 0000000000..d181ef8d30 --- /dev/null +++ b/server/test/services/node-red/mockPassword.js @@ -0,0 +1,11 @@ +module.exports = { + hash: (password) => + new Promise((resolve, reject) => { + resolve(password); + }), + compare: (password, hash) => + new Promise((resolve, reject) => { + resolve(true); + }), + generate: () => 'password', +}; diff --git a/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js b/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js index 3857ee2766..459ebab407 100644 --- a/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js +++ b/server/test/services/zigbee2mqtt/lib/getDiscoveredDevices.test.js @@ -39,9 +39,9 @@ describe('zigbee2mqtt getDiscoveredDevices', () => { // PREPARE gladys.stateManager.get .onFirstCall() - .returns({ room_id: 'room_id', name: 'device-name' }) + .returns({ id: 'gladys-id', room_id: 'room_id', name: 'device-name' }) .onSecondCall() - .returns(false) + .returns(expectedDevicesPayload[1]) .onThirdCall() .returns(false); @@ -56,4 +56,29 @@ describe('zigbee2mqtt getDiscoveredDevices', () => { // ASSERT expect(devices).deep.eq(expectedDevicesPayload); }); + + it('filter discovered devices', async () => { + // PREPARE + gladys.stateManager.get + .onFirstCall() + .returns({ id: 'gladys-id', room_id: 'room_id', name: 'device-name' }) + .onSecondCall() + .returns(expectedDevicesPayload[1]) + .onThirdCall() + .returns(false); + + discoveredDevices + .filter((d) => d.supported) + .forEach((device) => { + zigbee2MqttService.device.discoveredDevices[device.friendly_name] = device; + }); + + // EXECUTE + const devices = zigbee2MqttService.device.getDiscoveredDevices({ filter_existing: true }); + // ASSERT + // Expected devices but first + const filteredExpectedDevices = [...expectedDevicesPayload]; + filteredExpectedDevices.splice(1, 1); + expect(devices).deep.eq(filteredExpectedDevices); + }); }); diff --git a/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js b/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js index 51e790f77e..fd1ee9a9d1 100644 --- a/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js +++ b/server/test/services/zigbee2mqtt/lib/handleMqttMessage.test.js @@ -48,9 +48,9 @@ describe('zigbee2mqtt handleMqttMessage', () => { stateManagerGetStub = sinon.stub(); stateManagerGetStub .onFirstCall() - .returns({ room_id: 'room_id', name: 'device-name' }) + .returns({ id: 'gladys-id', room_id: 'room_id', name: 'device-name' }) .onSecondCall() - .returns(null) + .returns(expectedDevicesPayload[1]) .onThirdCall() .returns(null); zigbee2mqttManager.gladys.stateManager.get = stateManagerGetStub; diff --git a/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json b/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json index 43c4bbd703..036cef83a9 100644 --- a/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json +++ b/server/test/services/zigbee2mqtt/lib/payloads/event_device_result.json @@ -51,6 +51,7 @@ "unit": null } ], + "id": "gladys-id", "model": "WXKG01LM", "name": "device-name", "room_id": "room_id", @@ -62,6 +63,9 @@ "name": "0x00158d00033e88d1", "model": "TS0043", "external_id": "zigbee2mqtt:0x00158d00033e88d1", + "id": "gladys-id-2", + "room_id": "room_id", + "updatable": false, "features": [ { "name": "Battery", diff --git a/server/utils/constants.js b/server/utils/constants.js index 276d4362c8..bedc1c30fe 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -909,6 +909,10 @@ const WEBSOCKET_MESSAGE_TYPES = { STATUS: 'melcloud.status', DISCOVER: 'melcloud.discover', }, + NODERED: { + STATUS_CHANGE: 'nodered.status-change', + MQTT_ERROR: 'nodered.mqtt-error', + }, }; const DASHBOARD_TYPE = { @@ -958,6 +962,7 @@ const JOB_TYPES = { DEVICE_STATES_PURGE_SINGLE_FEATURE: 'device-state-purge-single-feature', VACUUM: 'vacuum', SERVICE_ZIGBEE2MQTT_BACKUP: 'service-zigbee2mqtt-backup', + SERVICE_NODE_RED_BACKUP: 'service-node-red-backup', }; const JOB_STATUS = { diff --git a/server/utils/password.js b/server/utils/password.js index 292481bf2d..30ab78827c 100644 --- a/server/utils/password.js +++ b/server/utils/password.js @@ -29,7 +29,7 @@ const generate = (length = 20, options = undefined) => { }; module.exports = { - hash: (password) => bcrypt.hash(password, SALT_ROUNDS), + hash: (password, hashRound = SALT_ROUNDS) => bcrypt.hash(password, hashRound), compare: (password, hash) => bcrypt.compare(password, hash), generate, };