Node-RED, back to the Cold War?#
Node-RED is a product from IBM Laboratories. It lets us create and manage workflows by interconnecting “nodes”.
A node connection can be a simple input –> output flow, or it can be combined with logic-processing nodes, other workflows…
Let your imagination run wild, according to your needs!
This article was originally written in 2020. Since then, Node-RED has evolved… and so has our installation. Rather than hiding the old approach, we are going to reconcile it with what now runs on our OpenBSD test server.
The goal remains the same: install Node-RED cleanly on OpenBSD. This time, however, we will also check each layer before adding the next one.
Objectives#
We will keep the following principles:
- Node.js is installed and maintained through OpenBSD packages;
- Node-RED runs as the dedicated
_node-reduser; - the persistent directory is
/data/node-red; - Node-RED is installed globally, while application modules remain local to the
userDir; - Node-RED is managed as a proper OpenBSD service with
rc.dandrcctl; - sensitive files are protected by default with
umask 077; - the
credentialSecretis explicitly controlled and kept separate from the Node-RED assets; - Node-RED listens only on
127.0.0.1:1880.
Previously, Node-RED handled HTTPS itself. Today it simply stays on HTTP over loopback; we will entrust secure publication to relayd.
We will come back to that choice. It deserves a little explanation. :)
Prerequisites#
A platform running OpenBSD.
The installation described here was actually rebuilt and tested on our OpenBSD test server with the following versions:
OpenBSD 8.0 arm64
Node.js v24.21.0
npm 11.19.0
Node-RED v5.0.7
Versions will change. The method is more interesting than the exact package number.
Installation#
Installing Node.js#
Let us first see which versions are available:
pkg_info -Q node
On our OpenBSD test server, the qualified version was:
node-24.21.0v0
We install it with the OpenBSD package manager:
doas pkg_add node-24.21.0v0
Then we check what was actually installed:
pkg_info | grep '^node-'
node --version
npm --version
npx --version
The result obtained during the rebuild was:
Node.js v24.21.0
npm 11.19.0
Our KISS rule: Node.js and npm remain under the responsibility of OpenBSD packages.
So we are not going to casually replace the npm supplied by OpenBSD with an npm install -g npm@....
Installing Node-RED#
Node-RED is our application runtime. We install it globally:
cd /home/ollie
doas npm install -g node-red@5.0.7
Then:
node-red --version
ls -l /usr/local/bin/node-red
npm list -g --depth=0
On our OpenBSD test server:
Node-RED v5.0.7
Node.js v24.21.0
OpenBSD 8.0 arm64 LE
What about node-red-admin?#
In the first version of this article, we installed this separately:
doas npm install -g node-red-admin
That is no longer necessary with our current version of Node-RED.
The command used, among other things, to generate a password hash is now directly available with:
node-red admin hash-pw
We will use it a little later.
The userDir: /data/node-red#
The service account used on our OpenBSD test server is:
_node-red:_node-red
with /data/node-red as the persistent directory.
This is where we keep, among other things:
flows.json
flows_cred.json
settings.js
.config.runtime.json
These four files do not have the same role.
flows.json contains the definition of our flows and properties that are not managed as credentials.
flows_cred.json contains the credentials associated with the flows, encrypted by Node-RED.
settings.js contains the runtime configuration.
And .config.runtime.json is also part of the persistent assets of the instance.
On our installation, we ultimately qualified these files with owner _node-red:_node-red and mode 0600:
doas chown _node-red:_node-red \
/data/node-red/flows.json \
/data/node-red/flows_cred.json \
/data/node-red/settings.js \
/data/node-red/.config.runtime.json
doas chmod 600 \
/data/node-red/flows.json \
/data/node-red/flows_cred.json \
/data/node-red/settings.js \
/data/node-red/.config.runtime.json
Why insist on this?
Before the rebuild, several operational files had been observed as 0644, including flows.json, flows_cred.json, .sessions.json, .config.users.json and some backups. settings.js had also been observed as 0660.
We did not establish what had created each of those modes. There is no point inventing a culprit.
We can, however, do better from now on.
Installing application modules#
The Node-RED runtime is global, but the modules used by our flows remain local to /data/node-red.
During the rebuild of our OpenBSD test server, four directly used modules were reintroduced:
node-red-contrib-bigtimer 2.8.6
node-red-contrib-smstools 1.1.1
node-red-contrib-watchdirectory 1.0.15
node-red-dashboard 3.6.5
We install them as the service account and with a restrictive umask:
doas -u _node-red sh -c '
cd /data/node-red &&
umask 077 &&
npm install --save-exact \
node-red-contrib-bigtimer@2.8.6 \
node-red-contrib-smstools@1.1.1 \
node-red-contrib-watchdirectory@1.0.15 \
node-red-dashboard@3.6.5
'
Then:
doas -u _node-red sh -c '
cd /data/node-red &&
npm ls --depth=0
'
The umask 077 is not there just to decorate the command.
By default, it ensures that a file created by the process grants no permissions to the group or other users. Since Node-RED and npm can create or modify flows, credentials, sessions, runtime files, package.json, package-lock.json, node_modules and files produced by modules, we may as well start from the principle of least privilege.
We will use the same principle again in our rc.d service.
First start: safe mode#
At this point we have Node.js, Node-RED, our assets and the modules.
We could start everything immediately.
We are not going to. :)
Node-RED has a safe mode (--safe) that loads the runtime, configuration, flows, credentials and modules without executing the flows.
Exactly what we need: check one layer before moving on to the next:
doas -u _node-red sh -c '
cd / &&
umask 077 &&
exec /usr/local/bin/node-red \
--safe \
--userDir /data/node-red \
--settings /data/node-red/settings.js
'
During the rebuild, this start loaded Node-RED 5.0.7, our four local modules, settings, flows and dashboard without a blocking error. The flows themselves remained stopped.
We also checked the listener:
netstat -an -f inet | grep '\.1880 '
doas fstat | grep ':1880'
ftp -Vo /dev/null http://127.0.0.1:1880/
Node-RED listens on:
127.0.0.1:1880
We can then cleanly stop this instance with Ctrl-C.
flows_cred.json and credentialSecret#
Safe mode also taught us something interesting.
Our flows_cred.json was already encrypted and Node-RED could read it perfectly well. The issue was therefore not converting clear-text credentials into an encrypted file.
No explicit credentialSecret had been defined. Node-RED was using an automatically generated key, with one simple risk: losing that key meant risking access to the encrypted credentials.
We therefore explicitly defined our credentialSecret, keeping it separate from the Node-RED assets.
The secret is stored in:
/etc/node-red-secret-settings
with:
owner = root:_node-red
mode = 0640
Its contents obviously remain secret. :)
A first attempt using the form credentialSecret=... reminded us of a simple rule: test what we write.
Node-RED rejected it:
Error processing -D option: /etc/node-red-secret-settings:
Unexpected token 'c', "credential"... is not valid JSON
The file passed through -D @file had to be JSON here.
After correcting the format, we tested the new configuration in safe mode without displaying the secret value.
The final service now loads this file with:
-D @/etc/node-red-secret-settings
After a Deploy -> Full, flows_cred.json changed: Node-RED had re-encrypted the credentials with the explicitly defined key. One additional restart then confirmed that the file remained stable.
That is why this option is now in our service. It is not a magic line added at the end of the installation.
What about credentials stored in the flows?#
The rebuild also identified historical application credentials stored directly in flows.json.
They were removed, and a check then confirmed the absence of the residual values we were looking for.
/etc/node-red-secret-settings protects the credentialSecret used by Node-RED for its credential store. It is not a general-purpose vault for every API key used by the flows.Protecting access to Node-RED#
The historical article used:
node-red-admin hash-pw
With Node-RED 5.0.7, we now use:
node-red admin hash-pw
Password: <Enter a password>
$2a$08$gxCsv3bGcPOvlarJiZw.QOqNfQKak7.vzltgRIAbGScbvGdOfE3HO
The principle remains the same: the result is a bcrypt hash that we can use in settings.js.
For example, to protect the editor and administration API:
adminAuth: {
type: "credentials",
users: [{
username: "admin",
password: "$2a$08$gxCsv3bGcPOvlarJiZw.QOqNfQKak7.vzltgRIAbGScbvGdOfE3HO",
permissions: "*"
}]
},
And to protect the HTTP endpoints defined by the nodes:
node-red admin hash-pw
Password: <Enter a password>
$2a$08$Fa6lO1rVaMPZYsxivz/PVOJoMly4/.Qi9EqMhReMMOLzutmVcC8Ty
then, for example:
httpNodeAuth: {
user: "dashUser",
pass: "$2a$08$Fa6lO1rVaMPZYsxivz/PVOJoMly4/.Qi9EqMhReMMOLzutmVcC8Ty"
},
On our OpenBSD test server, the authorizations already existed in the restored settings.js. The rebuild therefore did not recreate those accounts: it preserved those assets and then verified that they worked.
Starting normally#
Once safe mode had been qualified, we could start Node-RED normally with the same userDir, the same settings and our externalized secret.
We then check the real flows, MQTT, local HTTP access, authentication, the dashboard and the functions actually in use.
Let us view our test site through a tunnel:
ssh -L 1880:127.0.0.1:1880 ollie@test-server
Then from the local browser:
Editor http://127.0.0.1:1880/
Dashboard http://127.0.0.1:1880/ui
Port 1880 does not need to be exposed on the network.
Node-RED as an OpenBSD service#
At this point, Node-RED works manually.
Now let us write our startup script using rc.d:
#!/bin/ksh
daemon='/usr/local/bin/node-red'
daemon_flags='--userDir /data/node-red --settings /data/node-red/settings.js'
daemon_user='_node-red'
daemon_execdir='/'
rc_bg=YES
umask 077
. /etc/rc.d/rc.subr
rc_cmd $1
That gave us a rather amusing result.
Node-RED really was running: the process was listening on 127.0.0.1:1880, the HTTP service responded correctly… and we were rather pleased with the result. :)
… but our friend rcctl seemed a little confused:
rcctl start node_red -> node_red(ok)
rcctl check node_red -> node_red(failed)
Our investigation let us understand this paradox. Let us look at the process actually being executed:
pgrep -T 0 -xf 'node: node-red'
Node-RED is launched through Node.js, and the observed process title is:
node: node-red
So rcctl did start our service correctly, but did not find the process correctly during check.
We added:
pexp='node: node-red'
with one additional subtlety: this value is placed after loading rc.subr.
The service currently used on our OpenBSD test server is therefore:
#!/bin/ksh
daemon='/usr/local/bin/node-red'
daemon_flags='--userDir /data/node-red --settings /data/node-red/settings.js -D @/etc/node-red-secret-settings'
daemon_user='_node-red'
daemon_execdir='/'
rc_bg=YES
umask 077
. /etc/rc.d/rc.subr
# Node-RED is executed by Node.js; this is the exact process title
# observed and independently matched with pgrep -x.
pexp='node: node-red'
rc_cmd $1
We install it as:
/etc/rc.d/node_red
then:
doas chmod 755 /etc/rc.d/node_red
doas rcctl enable node_red
doas rcctl start node_red
sleep 8
doas rcctl check node_red
pgrep -fl 'node-red|red.js'
netstat -an -f inet | grep '\.1880 '
ftp -Vo /dev/null http://127.0.0.1:1880/
An RCA is often made of “whys”. So, why these few lines in our script? :)
- Because Node-RED does not daemonize itself in this mode, we let
rc.subrmanage it in the background withrc_bg=YES. - Because our manual launch had been tested from
/,daemon_execdir='/'makes our service independent of the current working directory. - Because files created by the service must retain restrictive permissions, our
umask 077belongs to the service, not to the shell that launchesrcctl.
Let us make sure our service does not regress#
Our friend rcctl has already given us one small surprise. Before considering our script finished, let us now check its full lifecycle. :)
First, stop Node-RED:
doas rcctl stop node_red
sleep 3
doas rcctl check node_red || true
pgrep -fl 'node-red|red.js' || \
echo 'NODE_RED_PROCESS=NONE'
netstat -an -f inet | \
grep '\.1880 .*LISTEN' || \
echo 'TCP_1880_LISTENER=NONE'
No process, no listener? Good. Let us start it again:
doas rcctl start node_red
sleep 8
doas rcctl check node_red
This time, rcctl finds our service correctly. The process returns, the listener too, and our HTTP test responds.
We can therefore start, check and stop Node-RED cleanly with rc.d.
One detail can be surprising after a stop: a socket may remain in TIME_WAIT for a while. That is not Node-RED secretly carrying on with its life. :) What we check here is the absence of a process and a socket in LISTEN.
HTTP through Node-RED? Yes, but let us stay hidden… :)#
The old version of this article created a self-signed certificate and enabled HTTPS directly in Node-RED.
We changed our approach. Node-RED now listens over HTTP, but only on:
127.0.0.1:1880
Clear-text HTTP? Yes. Exposed on the network? No. And that makes all the difference.
Node-RED is no longer directly exposed to the network. We separate responsibilities:
Client
|
| HTTPS / TLS
v
relayd
|
| local HTTP
v
127.0.0.1:1880
|
v
Node-RED
Node-RED remains our application.
relayd, OpenBSD’s native reverse proxy, handles secure publication upstream.
This architecture also means we no longer need to keep the old .ssl directory among the Node-RED assets.
We deliberately stop here for relayd: that layer deserves its own article and its own qualification. We are not going to declare TLS or mTLS validated simply because Node-RED works perfectly over loopback.
What about after a reboot?#
We now have:
- a working runtime;
- protected data and credentials;
- a local-only listener;
- a service that
rcctlcan start, check and stop.
One fairly simple test remains: turn off the lights and see whether everything comes back. :)
doas reboot
After reconnecting, without any manual start:
uptime
mount | grep '/data'
doas rcctl check node_red
pgrep -fl 'node-red|red.js'
netstat -an -f inet | grep '\.1880 '
doas fstat | grep ':1880'
ftp -Vo /dev/null http://127.0.0.1:1880/
On our OpenBSD test server, the post-reboot test confirmed:
RAID1 = ONLINE
/data/node-red = MOUNTED
rcctl check node_red = OK
runtime owner = _node-red
127.0.0.1:1880 = LISTEN
HTTP = PASS
But a process and an open port are still not enough to prove that our home automation works.
So we also checked MQTT, the dashboard, real telemetry and a real shutter command with its returned state.
Node-RED was back.
And so was what it controls. This is not entirely anecdotal… I have already found myself locked outside. :)
To finish#
The first version of this article launched Node-RED from rc.local, installed node-red-admin separately and made Node-RED handle HTTPS directly.
That installation worked for years.
But our recent rebuild allowed us to revisit it calmly, layer by layer, and evolve several choices:
OpenBSD packages
|
v
Node.js / npm
Node-RED global
|
v
/data/node-red
flows + configuration + modules + credentials
/etc/node-red-secret-settings
|
v
controlled credentialSecret
/etc/rc.d/node_red
|
v
rcctl + umask 077
127.0.0.1:1880
|
v
local HTTP only
The most interesting part is ultimately not the list of commands.
It is the order in which we were able to verify them:
Node.js
|
Node-RED
|
assets + modules
|
safe mode
|
normal runtime
|
rc.d
|
start / check / stop
|
reboot
|
real functional test
At each stage, we tried to change only one thing, observe what actually happened and move on only once the previous step was understood.
That is how we discovered, among other things, that an rcctl start ... (ok) did not yet mean that our service was properly controlled by rc.d.
The next step will be to move one level up in the architecture:
Internet / client
|
HTTPS
|
relayd
|
HTTP loopback
|
Node-RED
And this time, we will leave Node-RED alone: publication security will be relayd’s problem.
Enjoy !