From 64ff55473ec4bb0ec851812b399e29899ba27ab4 Mon Sep 17 00:00:00 2001 From: Oliver Vainikko Date: Tue, 12 May 2026 19:46:19 +0000 Subject: [PATCH 1/3] Add generated Node-RED admin credentials --- .gitignore | 5 +- bin/iot_install | 49 +++-- bin/iot_nodered-password | 122 +++++++++++ bin/nodered_ensure_admin_auth | 328 +++++++++++++++++++++++++++ bin/nodered_password_cli.js | 353 ++++++++++++++++++++++++++++++ bin/nodered_starter | 6 +- bin/web_update_config.obsolete | 22 +- doc/faq.rst | 21 +- doc/installation-raspberry-pi.rst | 5 +- doc/quickstart-pi.rst | 5 +- doc/second-node.rst | 6 +- 11 files changed, 878 insertions(+), 44 deletions(-) create mode 100755 bin/iot_nodered-password create mode 100755 bin/nodered_ensure_admin_auth create mode 100644 bin/nodered_password_cli.js diff --git a/.gitignore b/.gitignore index 27fdd542..127fa333 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ __pycache__ .vscode/launch.json doc/_links.rst etc/wifi_credentials +.node-red/ +iotempower-admin-credentials +iotempower-admin-password.hash .gradle *.lock @@ -35,4 +38,4 @@ gc.properties cache.properties buildOutput*/ -.DS_Store \ No newline at end of file +.DS_Store diff --git a/bin/iot_install b/bin/iot_install index f0837f4d..fe609c6c 100755 --- a/bin/iot_install +++ b/bin/iot_install @@ -546,30 +546,39 @@ if [[ "$install_node_red" == 1 ]]; then init_nodejs deactivate # termux can't do the following in venv - do we need to specialize this? echo_format "Installing Node Red" - npm install --unsafe-perm node-red + npm install --unsafe-perm node-red bcryptjs mkdir -p "$HOME/.node-red" - cp "$IOTEMPOWER_LOCAL"/nodejs/node_modules/node-red/settings.js "$HOME/.node-red/" - nodered_config=~/.node-red/settings.js - - if ! grep -q "module.exports.httpAdminRoot = '/nodered';" "$nodered_config" ; then - cat << EOF >> "$nodered_config" -module.exports.httpAdminRoot = '/nodered'; -module.exports.httpNodeRoot = '/nodered'; -module.exports.adminAuth= { -type: "credentials", -users: [{ - username: "admin", - password: "\$2b\$08\$W5LDP3eTaIYjz5iJkKVwMu9JDg3cPFMUvBypMCmYA3fpjYQlzFC4e", - permissions: "*" -}] -}; + nodered_config="$HOME/.node-red/settings.js" + if [[ ! -f "$nodered_config" ]]; then + cp "$IOTEMPOWER_LOCAL"/nodejs/node_modules/node-red/settings.js "$nodered_config" + fi + + if ! IOTEMPOWER_NODE_RED_AUTH_QUIET=1 bash "$IOTEMPOWER_ROOT/bin/nodered_ensure_admin_auth" "$nodered_config"; then + echo "Failed to configure Node-RED admin authentication, aborting." 1>&2 + exit 1 + fi + if grep -q "IOTEMPOWER_NODE_RED_ADMIN_AUTH" "$nodered_config"; then +cat << EOF +Node-RED admin account created. + +Username: admin +Password file: ~/.node-red/iotempower-admin-credentials + +Run: + iot nodered-password show + iot nodered-password reset + iot nodered-password set EOF - pushd "$HOME/.node-red" + fi + pushd "$HOME/.node-red" + if ! npm ls @flowfuse/node-red-dashboard > /dev/null 2>&1; then npm i @flowfuse/node-red-dashboard # install dashboard 2 + fi + if ! npm ls node-red-contrib-influxdb > /dev/null 2>&1; then npm i node-red-contrib-influxdb # install influxdb connector - popd - echo_format "Changed $nodered_config." - fi # node-red config + fi + popd + echo_format "Changed $nodered_config." activate fi # node-red diff --git a/bin/iot_nodered-password b/bin/iot_nodered-password new file mode 100755 index 00000000..08951d91 --- /dev/null +++ b/bin/iot_nodered-password @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +usage() { +cat << EOF +Syntax: iot nodered-password + +Manage the IoTempower-generated Node-RED admin password. + +Commands: + status Show whether IoTempower-managed Node-RED auth is configured. + show Print the generated username and password if the recovery file exists and is mode 600. + reset Generate a new password and update the bcrypt hash plus recovery file. + set Prompt twice for a new password without echoing it, then update the hash plus recovery file. + +The password is never accepted as a command-line argument, to avoid shell +history exposure. +EOF +} + +if [[ "$1" = "help" || "$1" = "-h" || "$1" = "--help" || -z "$1" ]]; then + usage + exit 0 +fi + +[ "$IOTEMPOWER_ACTIVE" = "yes" ] || { echo "IoTempower not active, aborting." 1>&2; exit 1; } + +command="$1" +shift + +nodered_user_dir="${IOTEMPOWER_NODE_RED_USER_DIR:-$HOME/.node-red}" +nodered_config="${IOTEMPOWER_NODE_RED_SETTINGS_FILE:-$nodered_user_dir/settings.js}" +node_cli="$IOTEMPOWER_ROOT/bin/nodered_password_cli.js" + +if [[ ! -f "$node_cli" ]]; then + echo "Cannot find Node-RED password helper: $node_cli" 1>&2 + exit 1 +fi + +ensure_managed_auth() { + if ! bash "$IOTEMPOWER_ROOT/bin/nodered_ensure_admin_auth" "$nodered_config"; then + echo "Failed to configure IoTempower-managed Node-RED admin authentication." 1>&2 + exit 1 + fi + if ! node "$node_cli" is-managed "$nodered_config"; then + echo "Node-RED has custom adminAuth; not changing credentials." 1>&2 + exit 1 + fi +} + +restart_reminder() { +cat << EOF + +Restart Node-RED for the change to take effect: + iot service restart web +EOF +} + +read_password_hidden() { + local prompt="$1" + local value="" + + if [[ -t 0 && -e /dev/tty ]]; then + IFS= read -r -s -p "$prompt" value < /dev/tty + printf '\n' > /dev/tty + else + IFS= read -r -s value + fi + printf '%s' "$value" +} + +case "$command" in + status) + [[ $# -eq 0 ]] || { usage 1>&2; exit 1; } + node "$node_cli" status "$nodered_config" + ;; + show) + [[ $# -eq 0 ]] || { usage 1>&2; exit 1; } + node "$node_cli" show "$nodered_config" + ;; + reset) + [[ $# -eq 0 ]] || { usage 1>&2; exit 1; } + ensure_managed_auth + if ! node "$node_cli" reset "$nodered_config"; then + exit 1 + fi + restart_reminder + ;; + set) + [[ $# -eq 0 ]] || { + echo "Do not pass the password on the command line. Run: iot nodered-password set" 1>&2 + exit 1 + } + ensure_managed_auth + password_one="$(read_password_hidden "New Node-RED admin password: ")" + password_two="$(read_password_hidden "Repeat Node-RED admin password: ")" + if [[ "$password_one" != "$password_two" ]]; then + echo "Passwords do not match." 1>&2 + exit 1 + fi + if [[ -z "$password_one" ]]; then + echo "Password must not be empty." 1>&2 + exit 1 + fi + if ! printf '%s' "$password_one" | node "$node_cli" set "$nodered_config"; then + unset password_one password_two + exit 1 + fi + unset password_one password_two + restart_reminder + ;; + ensure-files) + [[ $# -le 1 ]] || { usage 1>&2; exit 1; } + if [[ "${1:-}" ]]; then + nodered_config="$1" + fi + node "$node_cli" ensure-files "$nodered_config" + ;; + *) + usage 1>&2 + exit 1 + ;; +esac diff --git a/bin/nodered_ensure_admin_auth b/bin/nodered_ensure_admin_auth new file mode 100755 index 00000000..6dfc60f8 --- /dev/null +++ b/bin/nodered_ensure_admin_auth @@ -0,0 +1,328 @@ +#!/usr/bin/env bash + +if [[ $# -gt 1 || "$*" = "help" || "$*" = "-h" || "$*" = "--help" ]]; then +cat << EOF +Syntax: nodered_ensure_admin_auth [settings.js] + +Ensure the IoTempower Node-RED settings file uses generated local admin +credentials instead of a shared default password. + +EOF +exit 1 +fi + +[ "$IOTEMPOWER_ACTIVE" = "yes" ] || { echo "IoTempower not active, aborting." 1>&2;exit 1; } + +nodered_user_dir="$HOME/.node-red" +nodered_config="${1:-$nodered_user_dir/settings.js}" +nodered_config_dir="$(dirname "$nodered_config")" +nodered_credentials_file="${IOTEMPOWER_NODE_RED_CREDENTIALS_FILE:-$nodered_config_dir/iotempower-admin-credentials}" +nodered_template="$IOTEMPOWER_LOCAL/nodejs/node_modules/node-red/settings.js" +node_red_password_cli="$IOTEMPOWER_ROOT/bin/nodered_password_cli.js" +iotempower_auth_marker="IOTEMPOWER_NODE_RED_ADMIN_AUTH" +iotempower_old_default_hash='$2b$08$W5LDP3eTaIYjz5iJkKVwMu9JDg3cPF' +iotempower_old_default_hash+='MUvBypMCmYA3fpjYQlzFC4e' + +nodered_info() { + if [[ "${IOTEMPOWER_NODE_RED_AUTH_QUIET:-}" != "1" ]]; then + echo "$@" + fi +} + +nodered_has_admin_auth() { + grep -Eq '^[[:space:]]*module\.exports\.adminAuth[[:space:]]*=' "$nodered_config" \ + || grep -Eq '^[[:space:]]*adminAuth[[:space:]]*:' "$nodered_config" +} + +nodered_has_http_root_settings() { + grep -Eq '^[[:space:]]*(module\.exports\.)?httpAdminRoot[[:space:]]*[:=]' "$nodered_config" \ + || grep -Eq '^[[:space:]]*(module\.exports\.)?httpNodeRoot[[:space:]]*[:=]' "$nodered_config" +} + +nodered_ensure_default_http_roots() { + if nodered_has_http_root_settings; then + return 0 + fi + + if ! cat << 'EOF' >> "$nodered_config"; then +module.exports.httpAdminRoot = '/nodered'; +module.exports.httpNodeRoot = '/nodered'; +EOF + echo "Cannot update Node-RED HTTP root settings in $nodered_config." 1>&2 + exit 1 + fi +} + +nodered_require_bcryptjs_available() { + IOTEMPOWER_LOCAL="$IOTEMPOWER_LOCAL" node << 'EOF' +const path = require("path"); +const candidates = ["bcryptjs"]; +if (process.env.IOTEMPOWER_LOCAL) { + candidates.push(path.join(process.env.IOTEMPOWER_LOCAL, "nodejs", "node_modules", "bcryptjs")); + candidates.push(path.join(process.env.IOTEMPOWER_LOCAL, "nodejs", "node_modules", "node-red", "node_modules", "bcryptjs")); +} +for (const candidate of candidates) { + try { + require(candidate); + process.exit(0); + } catch (err) { + // Try the next known Node-RED install location. + } +} +process.exit(1); +EOF +} + +nodered_ensure_credential_files() { + if [[ ! -f "$node_red_password_cli" ]]; then + echo "Cannot find Node-RED password helper at $node_red_password_cli." 1>&2 + exit 1 + fi + if [[ "${IOTEMPOWER_NODE_RED_AUTH_QUIET:-}" = "1" ]]; then + local output + if ! output="$(node "$node_red_password_cli" ensure-files "$nodered_config" 2>&1)"; then + printf '%s\n' "$output" 1>&2 + echo "Cannot prepare IoTempower Node-RED admin credential files." 1>&2 + exit 1 + fi + return 0 + fi + if ! node "$node_red_password_cli" ensure-files "$nodered_config"; then + echo "Cannot prepare IoTempower Node-RED admin credential files." 1>&2 + exit 1 + fi +} + +if ! mkdir -p "$nodered_config_dir"; then + echo "Cannot create Node-RED settings directory $nodered_config_dir." 1>&2 + exit 1 +fi + +if [[ ! -f "$nodered_config" ]]; then + if [[ ! -f "$nodered_template" ]]; then + echo "Cannot find Node-RED settings template at $nodered_template." 1>&2 + exit 1 + fi + if ! cp "$nodered_template" "$nodered_config"; then + echo "Cannot copy Node-RED settings template to $nodered_config." 1>&2 + exit 1 + fi +fi + +if grep -Fq "$iotempower_old_default_hash" "$nodered_config"; then + old_hash_count=$(grep -Fc "$iotempower_old_default_hash" "$nodered_config") + if ! NODE_RED_OLD_DEFAULT_HASH="$iotempower_old_default_hash" \ + NODE_RED_CONFIG="$nodered_config" node << 'EOF'; then +const fs = require("fs"); +const file = process.env.NODE_RED_CONFIG; +const hash = process.env.NODE_RED_OLD_DEFAULT_HASH; +const escapedHash = hash.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const legacyDefault = new RegExp( + "\\n?module\\.exports\\.adminAuth\\s*=\\s*\\{\\s*" + + "type\\s*:\\s*\"credentials\"\\s*,\\s*" + + "users\\s*:\\s*\\[\\s*\\{\\s*" + + "username\\s*:\\s*\"admin\"\\s*,\\s*" + + "password\\s*:\\s*\"" + escapedHash + "\"\\s*,\\s*" + + "permissions\\s*:\\s*\"\\*\"\\s*" + + "\\}\\s*\\]\\s*\\}\\s*;\\s*", + "gs" +); +const text = fs.readFileSync(file, "utf8"); +fs.writeFileSync(file, text.replace(legacyDefault, "\n")); +EOF + echo "Cannot remove legacy Node-RED adminAuth from $nodered_config." 1>&2 + exit 1 + fi + new_hash_count=$(grep -Fc "$iotempower_old_default_hash" "$nodered_config" || true) + if [[ "$new_hash_count" -lt "$old_hash_count" ]]; then + nodered_info "Removed legacy IoTempower Node-RED admin/iotempire auth block from $nodered_config." + fi + if [[ "$new_hash_count" -gt 0 ]] && nodered_has_admin_auth; then + echo "Legacy IoTempower Node-RED password hash remains inside custom adminAuth in $nodered_config; manual migration required." 1>&2 + exit 1 + fi +fi + +if grep -q "$iotempower_auth_marker" "$nodered_config"; then + nodered_ensure_default_http_roots + nodered_ensure_credential_files + exit 0 +fi + +if nodered_has_admin_auth; then + nodered_info "Node-RED adminAuth already exists in $nodered_config; leaving adminAuth unchanged." + exit 0 +fi + +if ! nodered_require_bcryptjs_available; then + echo "Cannot load bcryptjs for IoTempower Node-RED adminAuth; adminAuth was not changed in $nodered_config." 1>&2 + exit 1 +fi + +nodered_ensure_default_http_roots + +if ! cat << 'EOF' >> "$nodered_config"; then + +/* IOTEMPOWER_NODE_RED_ADMIN_AUTH + * Uses per-install local admin credentials prepared by IoTempower. + * Node-RED can still generate them if this settings file is started directly. + * The password is kept out of this settings file; read it from: + * ~/.node-red/iotempower-admin-credentials + */ +function iotempowerNodeRedAdminPasswordHash() { + const fs = require("fs"); + const path = require("path"); + const crypto = require("crypto"); + const userDir = __dirname; + const credentialsFile = process.env.IOTEMPOWER_NODE_RED_CREDENTIALS_FILE || + path.join(userDir, "iotempower-admin-credentials"); + const hashFile = process.env.IOTEMPOWER_NODE_RED_PASSWORD_HASH_FILE || + path.join(userDir, "iotempower-admin-password.hash"); + + function chmodPrivate(file) { + try { + fs.chmodSync(file, 0o600); + } catch (err) { + // Best effort for filesystems that do not support POSIX modes. + } + } + + function readFirstLine(file) { + try { + return fs.readFileSync(file, "utf8").split(/\r?\n/, 1)[0].trim(); + } catch (err) { + if (err && err.code === "ENOENT") { + return ""; + } + throw err; + } + } + + function fileExists(file) { + try { + fs.accessSync(file, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } + } + + function credentialsFileHasPassword(file) { + try { + const content = fs.readFileSync(file, "utf8"); + return /^Password:\s*\S+/m.test(content); + } catch (err) { + if (err && err.code === "ENOENT") { + return false; + } + throw err; + } + } + + function removeIfExists(file) { + try { + fs.unlinkSync(file); + } catch (err) { + if (!err || err.code !== "ENOENT") { + throw err; + } + } + } + + function writePrivateAtomic(file, content) { + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + try { + fs.writeFileSync(tmp, content, { mode: 0o600 }); + chmodPrivate(tmp); + fs.renameSync(tmp, file); + chmodPrivate(file); + } catch (err) { + try { + fs.unlinkSync(tmp); + } catch (cleanupErr) { + if (!cleanupErr || cleanupErr.code !== "ENOENT") { + // Keep the original failure clearer for operators. + } + } + throw err; + } + } + + const existingHash = readFirstLine(hashFile); + if (existingHash && fileExists(credentialsFile) && credentialsFileHasPassword(credentialsFile)) { + chmodPrivate(hashFile); + chmodPrivate(credentialsFile); + return existingHash; + } + if (existingHash) { + removeIfExists(hashFile); + removeIfExists(credentialsFile); + } + + const candidates = ["bcryptjs"]; + if (process.env.IOTEMPOWER_LOCAL) { + candidates.push(path.join(process.env.IOTEMPOWER_LOCAL, "nodejs", "node_modules", "bcryptjs")); + candidates.push(path.join(process.env.IOTEMPOWER_LOCAL, "nodejs", "node_modules", "node-red", "node_modules", "bcryptjs")); + } + + let bcryptjs; + let loadError; + for (const candidate of candidates) { + try { + bcryptjs = require(candidate); + break; + } catch (err) { + loadError = err; + } + } + if (!bcryptjs) { + const detail = loadError && loadError.message ? ` ${loadError.message}` : ""; + throw new Error(`Cannot load bcryptjs for IoTempower Node-RED adminAuth.${detail}`); + } + + const password = crypto.randomBytes(24).toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + const hash = bcryptjs.hashSync(password, 8); + + const credentialsContent = [ + "IoTempower Node-RED admin credentials", + "Username: admin", + `Password: ${password}`, + `URL path: ${module.exports.httpAdminRoot || "/nodered"}`, + "", + "Keep this file private. To rotate the generated password, stop Node-RED,", + "run `iot nodered-password reset` or `iot nodered-password set`, then restart Node-RED.", + "", + ].join("\n"); + + writePrivateAtomic(credentialsFile, credentialsContent); + try { + writePrivateAtomic(hashFile, `${hash}\n`); + } catch (err) { + removeIfExists(credentialsFile); + throw err; + } + return hash; +} + +module.exports.adminAuth = { + type: "credentials", + users: [{ + username: "admin", + password: iotempowerNodeRedAdminPasswordHash(), + permissions: "*" + }] +}; +/* IOTEMPOWER_NODE_RED_ADMIN_AUTH_END */ +EOF + echo "Cannot append generated Node-RED adminAuth block to $nodered_config." 1>&2 + exit 1 +fi + +nodered_ensure_credential_files + +nodered_info "Configured generated Node-RED admin credentials in $nodered_config." +nodered_info "Password file: $nodered_credentials_file" +nodered_info "Run: iot nodered-password show" diff --git a/bin/nodered_password_cli.js b/bin/nodered_password_cli.js new file mode 100644 index 00000000..e9f4e438 --- /dev/null +++ b/bin/nodered_password_cli.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const action = process.argv[2] || "status"; +const marker = "IOTEMPOWER_NODE_RED_ADMIN_AUTH"; +const oldDefaultHash = "$2b$08$W5LDP3eTaIYjz5iJkKVwMu9JDg3cPF" + + "MUvBypMCmYA3fpjYQlzFC4e"; +const username = "admin"; + +const userDir = process.env.IOTEMPOWER_NODE_RED_USER_DIR || + path.join(process.env.HOME || "", ".node-red"); +const settingsFile = process.env.IOTEMPOWER_NODE_RED_SETTINGS_FILE || + process.argv[3] || + path.join(userDir, "settings.js"); +const settingsDir = path.dirname(settingsFile); +const credentialsFile = process.env.IOTEMPOWER_NODE_RED_CREDENTIALS_FILE || + path.join(settingsDir, "iotempower-admin-credentials"); +const hashFile = process.env.IOTEMPOWER_NODE_RED_PASSWORD_HASH_FILE || + path.join(settingsDir, "iotempower-admin-password.hash"); + +function exists(file) { + try { + fs.accessSync(file, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } +} + +function readText(file) { + return fs.readFileSync(file, "utf8"); +} + +function readFirstLine(file) { + try { + return readText(file).split(/\r?\n/, 1)[0].trim(); + } catch (err) { + if (err && err.code === "ENOENT") { + return ""; + } + throw err; + } +} + +function modeOf(file) { + try { + return fs.statSync(file).mode & 0o777; + } catch (err) { + if (err && err.code === "ENOENT") { + return null; + } + throw err; + } +} + +function formatMode(mode) { + return mode === null ? "missing" : mode.toString(8).padStart(3, "0"); +} + +function chmodPrivate(file) { + try { + fs.chmodSync(file, 0o600); + } catch (err) { + // Best effort for filesystems without POSIX mode support. + } +} + +function removeIfExists(file) { + try { + fs.unlinkSync(file); + } catch (err) { + if (!err || err.code !== "ENOENT") { + throw err; + } + } +} + +function writePrivateAtomic(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + try { + fs.writeFileSync(tmp, content, { mode: 0o600 }); + chmodPrivate(tmp); + fs.renameSync(tmp, file); + chmodPrivate(file); + } catch (err) { + removeIfExists(tmp); + throw err; + } +} + +function loadBcrypt() { + const candidates = ["bcryptjs"]; + if (process.env.IOTEMPOWER_LOCAL) { + candidates.push(path.join(process.env.IOTEMPOWER_LOCAL, "nodejs", "node_modules", "bcryptjs")); + candidates.push(path.join(process.env.IOTEMPOWER_LOCAL, "nodejs", "node_modules", "node-red", "node_modules", "bcryptjs")); + } + if (process.env.IOTEMPOWER_NODE_RED_BCRYPTJS_PATH) { + candidates.unshift(process.env.IOTEMPOWER_NODE_RED_BCRYPTJS_PATH); + } + + let lastError; + for (const candidate of candidates) { + try { + return require(candidate); + } catch (err) { + lastError = err; + } + } + + const detail = lastError && lastError.message ? ` ${lastError.message}` : ""; + throw new Error(`Cannot load bcryptjs for IoTempower Node-RED adminAuth.${detail}`); +} + +function readSettingsInfo() { + if (!exists(settingsFile)) { + return { + exists: false, + managed: false, + customAdminAuth: false, + legacyDefaultHash: false, + }; + } + const text = readText(settingsFile); + return { + exists: true, + managed: text.includes(marker), + customAdminAuth: /^[ \t]*module\.exports\.adminAuth[ \t]*=/m.test(text) || + /^[ \t]*adminAuth[ \t]*:/m.test(text), + legacyDefaultHash: text.includes(oldDefaultHash), + }; +} + +function readHttpAdminRoot() { + if (!exists(settingsFile)) { + return "/nodered"; + } + const text = readText(settingsFile); + const patterns = [ + /(?:module\.exports\.)?httpAdminRoot\s*=\s*(['"`])([^'"`]+)\1/m, + /httpAdminRoot\s*:\s*(['"`])([^'"`]+)\1/m, + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match) { + return match[2]; + } + } + return "/nodered"; +} + +function readPasswordFromCredentials() { + const content = readText(credentialsFile); + const match = content.match(/^Password:[ \t]*(.*)$/m); + return match ? match[1] : ""; +} + +function fileSummary(file) { + const mode = modeOf(file); + return { + exists: mode !== null, + mode, + modeOk: mode === 0o600, + }; +} + +function consistency() { + const settings = readSettingsInfo(); + const credentials = fileSummary(credentialsFile); + const hash = fileSummary(hashFile); + const hashValue = readFirstLine(hashFile); + let password = ""; + let recoveryReadable = false; + let hashMatchesRecovery = "not-checkable"; + + if (credentials.exists) { + try { + password = readPasswordFromCredentials(); + recoveryReadable = password.length > 0; + } catch (err) { + recoveryReadable = false; + } + } + + if (settings.managed && recoveryReadable && hashValue) { + try { + hashMatchesRecovery = loadBcrypt().compareSync(password, hashValue) ? "yes" : "no"; + } catch (err) { + hashMatchesRecovery = "error"; + } + } + + const ready = settings.managed && + credentials.exists && + hash.exists && + credentials.modeOk && + hash.modeOk && + recoveryReadable && + hashMatchesRecovery === "yes"; + + return { + settings, + credentials, + hash, + recoveryReadable, + hashMatchesRecovery, + ready, + }; +} + +function generatePassword() { + return crypto.randomBytes(24).toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function credentialsContent(password) { + return [ + "IoTempower Node-RED admin credentials", + `Username: ${username}`, + `Password: ${password}`, + `URL path: ${readHttpAdminRoot()}`, + "", + "Keep this file private.", + "Use `iot nodered-password reset` or `iot nodered-password set` to rotate it.", + "", + ].join("\n"); +} + +function requireManagedSettings() { + const settings = readSettingsInfo(); + if (!settings.exists) { + throw new Error(`Node-RED settings file does not exist: ${settingsFile}`); + } + if (!settings.managed) { + if (settings.customAdminAuth) { + throw new Error(`Node-RED settings has custom adminAuth; leaving it unchanged: ${settingsFile}`); + } + throw new Error(`IoTempower-managed Node-RED adminAuth is not configured in ${settingsFile}`); + } +} + +function writePassword(password) { + if (!password || !/\S/.test(password)) { + throw new Error("Password must not be empty."); + } + if (/[\r\n]/.test(password)) { + throw new Error("Password must be a single line."); + } + if (password !== password.trim()) { + throw new Error("Password must not start or end with whitespace."); + } + requireManagedSettings(); + const bcrypt = loadBcrypt(); + const hash = bcrypt.hashSync(password, 8); + writePrivateAtomic(credentialsFile, credentialsContent(password)); + try { + writePrivateAtomic(hashFile, `${hash}\n`); + } catch (err) { + removeIfExists(credentialsFile); + throw err; + } + return password; +} + +function ensureFiles() { + requireManagedSettings(); + const state = consistency(); + if (state.ready) { + chmodPrivate(credentialsFile); + chmodPrivate(hashFile); + return { changed: false, password: readPasswordFromCredentials() }; + } + const password = writePassword(generatePassword()); + return { changed: true, password }; +} + +function printStatus() { + const state = consistency(); + console.log(`Node-RED settings: ${settingsFile}`); + console.log(`IoTempower-managed auth: ${state.settings.managed ? "yes" : "no"}`); + if (!state.settings.managed && state.settings.customAdminAuth) { + console.log("Admin auth type: custom"); + } + console.log(`Username: ${state.settings.managed ? username : "n/a"}`); + console.log(`Credential file: ${credentialsFile}`); + console.log(`Credential file mode: ${formatMode(state.credentials.mode)}${state.credentials.modeOk ? " (ok)" : ""}`); + console.log(`Hash file: ${hashFile}`); + console.log(`Hash file mode: ${formatMode(state.hash.mode)}${state.hash.modeOk ? " (ok)" : ""}`); + console.log(`Recovery file has password: ${state.recoveryReadable ? "yes" : "no"}`); + console.log(`Recovery/hash consistent: ${state.hashMatchesRecovery}`); + console.log(`Legacy default hash present: ${state.settings.legacyDefaultHash ? "yes" : "no"}`); + console.log(`Status: ${state.ready ? "ready" : "needs attention"}`); + return state.ready ? 0 : 1; +} + +function showPassword() { + const state = consistency(); + if (!state.settings.managed) { + throw new Error("IoTempower-managed Node-RED auth is not configured."); + } + if (!state.credentials.exists) { + throw new Error(`Credential file does not exist: ${credentialsFile}. Run: iot nodered-password reset`); + } + if (!state.credentials.modeOk) { + throw new Error(`Refusing to print credentials because ${credentialsFile} mode is ${formatMode(state.credentials.mode)}, expected 600.`); + } + const password = readPasswordFromCredentials(); + if (!password) { + throw new Error(`Credential file has no readable Password line: ${credentialsFile}. Run: iot nodered-password reset`); + } + console.log(`Username: ${username}`); + console.log(`Password: ${password}`); +} + +function readStdin() { + return fs.readFileSync(0, "utf8").replace(/\r?\n$/, ""); +} + +try { + if (action === "status") { + process.exit(printStatus()); + } else if (action === "is-managed") { + process.exit(readSettingsInfo().managed ? 0 : 1); + } else if (action === "show") { + showPassword(); + } else if (action === "ensure-files") { + const result = ensureFiles(); + if (result.changed) { + console.log(`Created Node-RED admin credential recovery file: ${credentialsFile}`); + } + } else if (action === "reset") { + writePassword(generatePassword()); + console.log("Node-RED admin password reset."); + console.log(`Username: ${username}`); + console.log(`Password file: ${credentialsFile}`); + } else if (action === "set") { + writePassword(readStdin()); + console.log("Node-RED admin password updated."); + console.log(`Username: ${username}`); + console.log(`Password file: ${credentialsFile}`); + } else { + throw new Error(`Unknown action: ${action}`); + } +} catch (err) { + console.error(err && err.message ? err.message : String(err)); + process.exit(1); +} diff --git a/bin/nodered_starter b/bin/nodered_starter index 992f22a4..ed2e8994 100755 --- a/bin/nodered_starter +++ b/bin/nodered_starter @@ -11,10 +11,14 @@ fi [ "$IOTEMPOWER_ACTIVE" = "yes" ] || { echo "IoTempower not active, aborting." 1>&2;exit 1; } +if ! bash "$IOTEMPOWER_ROOT/bin/nodered_ensure_admin_auth"; then + echo "Failed to configure Node-RED admin authentication, aborting." 1>&2 + exit 1 +fi + while true; do sleep 1 # be conservative on ram usage (https://github.com/node-red/node-red-dashboard/issues/144) node-red --node-args="--max-old-space-size=256" sleep 1 done - diff --git a/bin/web_update_config.obsolete b/bin/web_update_config.obsolete index e5844b1b..3cc1c62c 100755 --- a/bin/web_update_config.obsolete +++ b/bin/web_update_config.obsolete @@ -12,22 +12,14 @@ fi nodered_config=~/.node-red/settings.js -if ! grep -q "module.exports.httpAdminRoot = '/nodered';" "$nodered_config" ; then - cat << EOF >> "$nodered_config" -module.exports.httpAdminRoot = '/nodered'; -module.exports.httpNodeRoot = '/nodered'; -module.exports.adminAuth= { - type: "credentials", - users: [{ - username: "admin", - password: "\$2b\$08\$W5LDP3eTaIYjz5iJkKVwMu9JDg3cPFMUvBypMCmYA3fpjYQlzFC4e", - permissions: "*" - }] -}; -EOF - echo "Changed $nodered_config." +if [[ "$IOTEMPOWER_ACTIVE" = "yes" && -f "$IOTEMPOWER_ROOT/bin/nodered_ensure_admin_auth" ]]; then + if ! bash "$IOTEMPOWER_ROOT/bin/nodered_ensure_admin_auth" "$nodered_config"; then + echo "Failed to configure Node-RED admin authentication, aborting." 1>&2 + exit 1 + fi else - echo "No change in $nodered_config necessary." + echo "IoTempower environment not active; cannot configure Node-RED admin auth." 1>&2 + exit 1 fi cloudcmd_config=~/.cloudcmd.json diff --git a/doc/faq.rst b/doc/faq.rst index d66e325e..d0bed9c0 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -257,7 +257,26 @@ Yes! Node-RED works great with IoTempower. Use MQTT nodes in Node-RED to subscri What is the Node-RED password after starting web_starter? ---------------------------------------------------------- -Many default passwords in IoTempower are set to ``iotempire`` for simplicity in classroom environments. Yes, this is a security concern, but we opt for that for ease of use in educational settings. Feel free to enhance security of IoTempower on different levels without compromising usability. +Default IoTempower-managed Node-RED installs use the username ``admin`` and a +locally generated password. On the gateway, run: + +.. code-block:: bash + + iot nodered-password show + +If you configured custom Node-RED authentication, use those custom credentials. +To rotate or choose the generated password, run one of: + +.. code-block:: bash + + iot nodered-password reset + iot nodered-password set + +Then restart Node-RED: + +.. code-block:: bash + + iot service restart web How do I run custom code on my nodes? diff --git a/doc/installation-raspberry-pi.rst b/doc/installation-raspberry-pi.rst index 55649565..7bf3f22c 100644 --- a/doc/installation-raspberry-pi.rst +++ b/doc/installation-raspberry-pi.rst @@ -51,8 +51,9 @@ Tutorial video for setting up the sd-card for the pi: https://youtu.be/FrIIXsseZ - On the raspberry pi IoTempower installation, you can use SSH to access and interact with your system (``ssh iot@iotgateway``, password ``iotempire``). -- If you are asked for a user, use ``iot``, if you are asked for a password - use ``iotempire``. +- If SSH asks for a user, use ``iot`` and password ``iotempire``. For a + default IoTempower-managed Node-RED install, use ``admin`` and run + ``iot nodered-password show`` on the gateway to read the generated password. - ssh access (this is for advanced users, usually you can just use the browser): diff --git a/doc/quickstart-pi.rst b/doc/quickstart-pi.rst index 2a1a97dc..b9a71b47 100644 --- a/doc/quickstart-pi.rst +++ b/doc/quickstart-pi.rst @@ -74,8 +74,9 @@ Accessing the Local Services on the Raspberry Pi You should now see the IoTempower homepage with links to Node-RED and the local documentation. -- If you are asked for a user, use ``iot`` (for Node-RED and portainer you want to use admin), - if you are asked for a password use ``iotempire``. +- If you are asked for a user, use ``iot``. For a default IoTempower-managed + Node-RED install, use ``admin`` and run ``iot nodered-password show`` on the + gateway to read the generated password. - Keep this home-page for later (remember or bookmark). diff --git a/doc/second-node.rst b/doc/second-node.rst index 786c52d0..d0b53d54 100644 --- a/doc/second-node.rst +++ b/doc/second-node.rst @@ -105,8 +105,10 @@ Visually Programming the Connections **Optional: Using the Raspberry Pi Image** If you're using the IoTempower Raspberry Pi image, you may need to enter - username (*admin*) and password (*iotempire*) to access Node-RED. You - will see a pre-configured flow with example nodes. + username (*admin*) and the generated password shown by + ``iot nodered-password show`` on the gateway to access a default + IoTempower-managed Node-RED install. You will see a pre-configured flow + with example nodes. On a fresh installation, you will start with an empty flow. From 3604cd336126860dac7d99b570c463bcc53e051c Mon Sep 17 00:00:00 2001 From: Oliver Vainikko Date: Wed, 13 May 2026 12:25:45 +0000 Subject: [PATCH 2/3] Preserve custom Node-RED admin auth forms --- bin/nodered_ensure_admin_auth | 42 +++++-- bin/nodered_password_cli.js | 60 ++++++++-- tests/test_nodered_first_run_admin.py | 163 ++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 23 deletions(-) create mode 100644 tests/test_nodered_first_run_admin.py diff --git a/bin/nodered_ensure_admin_auth b/bin/nodered_ensure_admin_auth index 6dfc60f8..a69c1ac5 100755 --- a/bin/nodered_ensure_admin_auth +++ b/bin/nodered_ensure_admin_auth @@ -29,25 +29,47 @@ nodered_info() { fi } +nodered_active_settings_lines() { + grep -Ev '^[[:space:]]*(//|/\*|\*)' "$nodered_config" +} + +nodered_has_js_setting() { + local setting="$1" + nodered_active_settings_lines | grep -Eq "(^|;)[[:space:]]*([[:alpha:]_$][[:alnum:]_$]*)(\\.[[:alpha:]_$][[:alnum:]_$]*)*\\.${setting}[[:space:]]*=" \ + || nodered_active_settings_lines | grep -Eq "(^|;)[[:space:]]*([[:alpha:]_$][[:alnum:]_$]*)(\\.[[:alpha:]_$][[:alnum:]_$]*)*[[:space:]]*\\[[[:space:]]*['\"]${setting}['\"][[:space:]]*\\][[:space:]]*=" \ + || nodered_active_settings_lines | grep -Eq "(^|[,{])[[:space:]]*${setting}[[:space:]]*:" \ + || nodered_active_settings_lines | grep -Eq "(^|[,{])[[:space:]]*['\"]${setting}['\"][[:space:]]*:" +} + nodered_has_admin_auth() { - grep -Eq '^[[:space:]]*module\.exports\.adminAuth[[:space:]]*=' "$nodered_config" \ - || grep -Eq '^[[:space:]]*adminAuth[[:space:]]*:' "$nodered_config" + nodered_has_js_setting "adminAuth" +} + +nodered_has_http_admin_root() { + nodered_has_js_setting "httpAdminRoot" } -nodered_has_http_root_settings() { - grep -Eq '^[[:space:]]*(module\.exports\.)?httpAdminRoot[[:space:]]*[:=]' "$nodered_config" \ - || grep -Eq '^[[:space:]]*(module\.exports\.)?httpNodeRoot[[:space:]]*[:=]' "$nodered_config" +nodered_has_http_node_root() { + nodered_has_js_setting "httpNodeRoot" } nodered_ensure_default_http_roots() { - if nodered_has_http_root_settings; then + local missing_roots=() + + if ! nodered_has_http_admin_root; then + missing_roots+=("module.exports.httpAdminRoot = '/nodered';") + fi + if ! nodered_has_http_node_root; then + missing_roots+=("module.exports.httpNodeRoot = '/nodered';") + fi + if [[ "${#missing_roots[@]}" -eq 0 ]]; then return 0 fi - if ! cat << 'EOF' >> "$nodered_config"; then -module.exports.httpAdminRoot = '/nodered'; -module.exports.httpNodeRoot = '/nodered'; -EOF + if ! { + printf '\n' + printf '%s\n' "${missing_roots[@]}" + } >> "$nodered_config"; then echo "Cannot update Node-RED HTTP root settings in $nodered_config." 1>&2 exit 1 fi diff --git a/bin/nodered_password_cli.js b/bin/nodered_password_cli.js index e9f4e438..a63a8cb4 100644 --- a/bin/nodered_password_cli.js +++ b/bin/nodered_password_cli.js @@ -116,6 +116,51 @@ function loadBcrypt() { throw new Error(`Cannot load bcryptjs for IoTempower Node-RED adminAuth.${detail}`); } +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function activeSettingsText(text) { + return text.split(/\r?\n/) + .filter((line) => !/^[ \t]*(?:\/\/|\/\*|\*)/.test(line)) + .join("\n"); +} + +const jsIdentifier = "[A-Za-z_$][A-Za-z0-9_$]*"; +const jsObjectPath = `${jsIdentifier}(?:\\.${jsIdentifier})*`; + +function hasJsSetting(text, setting) { + const source = activeSettingsText(text); + const name = escapeRegExp(setting); + const patterns = [ + new RegExp(`(^|;)[ \\t]*${jsObjectPath}\\.${name}[ \\t]*=`, "m"), + new RegExp(`(^|;)[ \\t]*${jsObjectPath}[ \\t]*\\[[ \\t]*['"]${name}['"][ \\t]*\\][ \\t]*=`, "m"), + new RegExp(`(^|[,{])[ \\t]*${name}[ \\t]*:`, "m"), + new RegExp(`(^|[,{])[ \\t]*['"]${name}['"][ \\t]*:`, "m"), + ]; + return patterns.some((pattern) => pattern.test(source)); +} + +function readStringSetting(text, setting, defaultValue) { + const source = activeSettingsText(text); + const name = escapeRegExp(setting); + const quotedValue = "(['\"`])([^'\"`]+)\\1"; + const patterns = [ + new RegExp(`(^|;)[ \\t]*${jsObjectPath}\\.${name}[ \\t]*=[ \\t]*${quotedValue}`, "m"), + new RegExp(`(^|;)[ \\t]*${jsObjectPath}[ \\t]*\\[[ \\t]*['"]${name}['"][ \\t]*\\][ \\t]*=[ \\t]*${quotedValue}`, "m"), + new RegExp(`(^|[,{])[ \\t]*${name}[ \\t]*:[ \\t]*${quotedValue}`, "m"), + new RegExp(`(^|[,{])[ \\t]*['"]${name}['"][ \\t]*:[ \\t]*${quotedValue}`, "m"), + ]; + + for (const pattern of patterns) { + const match = source.match(pattern); + if (match) { + return match[match.length - 1]; + } + } + return defaultValue; +} + function readSettingsInfo() { if (!exists(settingsFile)) { return { @@ -129,8 +174,7 @@ function readSettingsInfo() { return { exists: true, managed: text.includes(marker), - customAdminAuth: /^[ \t]*module\.exports\.adminAuth[ \t]*=/m.test(text) || - /^[ \t]*adminAuth[ \t]*:/m.test(text), + customAdminAuth: hasJsSetting(text, "adminAuth"), legacyDefaultHash: text.includes(oldDefaultHash), }; } @@ -140,17 +184,7 @@ function readHttpAdminRoot() { return "/nodered"; } const text = readText(settingsFile); - const patterns = [ - /(?:module\.exports\.)?httpAdminRoot\s*=\s*(['"`])([^'"`]+)\1/m, - /httpAdminRoot\s*:\s*(['"`])([^'"`]+)\1/m, - ]; - for (const pattern of patterns) { - const match = text.match(pattern); - if (match) { - return match[2]; - } - } - return "/nodered"; + return readStringSetting(text, "httpAdminRoot", "/nodered"); } function readPasswordFromCredentials() { diff --git a/tests/test_nodered_first_run_admin.py b/tests/test_nodered_first_run_admin.py new file mode 100644 index 00000000..844442f8 --- /dev/null +++ b/tests/test_nodered_first_run_admin.py @@ -0,0 +1,163 @@ +import os +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ENSURE_ADMIN_AUTH = REPO_ROOT / "bin" / "nodered_ensure_admin_auth" +PASSWORD_CLI = REPO_ROOT / "bin" / "nodered_password_cli.js" + +CUSTOM_ADMIN_AUTH_FORMS = [ + pytest.param( + 'module.exports = {\n adminAuth: { type: "credentials" }\n};\n', + id="bare-key", + ), + pytest.param( + 'module.exports = {\n "adminAuth": { type: "credentials" }\n};\n', + id="quoted-key", + ), + pytest.param( + 'module.exports["adminAuth"] = { type: "credentials" };\n', + id="bracket-assignment", + ), + pytest.param( + 'module.exports.adminAuth = { type: "credentials" };\n', + id="object-property", + ), + pytest.param( + 'module.exports = { flowFile: "flows.json", adminAuth: { type: "credentials" } };\n', + id="same-line-bare-key", + ), + pytest.param( + 'module.exports = {}; module.exports["adminAuth"] = { type: "credentials" };\n', + id="same-line-bracket-assignment", + ), +] + + +def fake_iotempower_local(tmp_path): + local_dir = tmp_path / "local" + bcrypt_dir = local_dir / "nodejs" / "node_modules" / "bcryptjs" + bcrypt_dir.mkdir(parents=True, exist_ok=True) + (bcrypt_dir / "index.js").write_text( + "\n".join( + [ + "module.exports = {", + ' hashSync: function(password) { return "hash-for-" + password; },', + " compareSync: function() { return true; }", + "};", + "", + ] + ), + encoding="utf-8", + ) + return local_dir + + +def iotempower_env(tmp_path): + env = os.environ.copy() + env.update( + { + "IOTEMPOWER_ACTIVE": "yes", + "IOTEMPOWER_ROOT": str(REPO_ROOT), + "IOTEMPOWER_LOCAL": str(fake_iotempower_local(tmp_path)), + "IOTEMPOWER_NODE_RED_AUTH_QUIET": "1", + } + ) + for key in ( + "IOTEMPOWER_NODE_RED_CREDENTIALS_FILE", + "IOTEMPOWER_NODE_RED_PASSWORD_HASH_FILE", + "IOTEMPOWER_NODE_RED_SETTINGS_FILE", + "IOTEMPOWER_NODE_RED_USER_DIR", + "IOTEMPOWER_NODE_RED_BCRYPTJS_PATH", + ): + env.pop(key, None) + return env + + +def run_ensure_admin_auth(settings_file, env): + return subprocess.run( + [str(ENSURE_ADMIN_AUTH), str(settings_file)], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + ) + + +@pytest.mark.parametrize("settings_text", CUSTOM_ADMIN_AUTH_FORMS) +def test_ensure_admin_auth_preserves_custom_admin_auth_forms(tmp_path, settings_text): + settings_file = tmp_path / "settings.js" + settings_file.write_text(settings_text, encoding="utf-8") + + result = run_ensure_admin_auth(settings_file, iotempower_env(tmp_path)) + + assert result.returncode == 0, result.stderr + assert settings_file.read_text(encoding="utf-8") == settings_text + assert not (tmp_path / "iotempower-admin-credentials").exists() + assert not (tmp_path / "iotempower-admin-password.hash").exists() + + +@pytest.mark.parametrize("settings_text", CUSTOM_ADMIN_AUTH_FORMS) +def test_password_cli_reports_custom_admin_auth_forms(tmp_path, settings_text): + settings_file = tmp_path / "settings.js" + settings_file.write_text(settings_text, encoding="utf-8") + + result = subprocess.run( + ["node", str(PASSWORD_CLI), "reset", str(settings_file)], + cwd=REPO_ROOT, + env=iotempower_env(tmp_path), + text=True, + capture_output=True, + ) + + assert result.returncode == 1 + assert "custom adminAuth" in result.stderr + + +@pytest.mark.parametrize( + "existing_root, existing_name, expected_added", + [ + pytest.param( + "module.exports.httpAdminRoot = '/custom-admin';", + "httpAdminRoot", + "module.exports.httpNodeRoot = '/nodered';", + id="preserve-existing-admin-root", + ), + pytest.param( + "module.exports['httpNodeRoot'] = '/custom-node';", + "httpNodeRoot", + "module.exports.httpAdminRoot = '/nodered';", + id="preserve-existing-node-root", + ), + ], +) +def test_managed_auth_adds_only_missing_default_http_root( + tmp_path, + existing_root, + existing_name, + expected_added, +): + settings_file = tmp_path / "settings.js" + settings_file.write_text( + "\n".join( + [ + existing_root, + "", + "/* IOTEMPOWER_NODE_RED_ADMIN_AUTH */", + "module.exports.adminAuth = {};", + "", + ] + ), + encoding="utf-8", + ) + + result = run_ensure_admin_auth(settings_file, iotempower_env(tmp_path)) + settings_text = settings_file.read_text(encoding="utf-8") + + assert result.returncode == 0, result.stderr + assert existing_root in settings_text + assert expected_added in settings_text + assert settings_text.count(existing_name) == 1 From 9b6be86449e5215e5d3db00ebd8f298900cd5294 Mon Sep 17 00:00:00 2001 From: Oliver Vainikko Date: Wed, 13 May 2026 14:06:27 +0000 Subject: [PATCH 3/3] Harden Node-RED settings detection --- bin/nodered_ensure_admin_auth | 24 +-- bin/nodered_password_cli.js | 203 +++++++++++++++++++++----- tests/test_nodered_first_run_admin.py | 51 +++++++ 3 files changed, 235 insertions(+), 43 deletions(-) diff --git a/bin/nodered_ensure_admin_auth b/bin/nodered_ensure_admin_auth index a69c1ac5..d837b42e 100755 --- a/bin/nodered_ensure_admin_auth +++ b/bin/nodered_ensure_admin_auth @@ -19,7 +19,6 @@ nodered_config_dir="$(dirname "$nodered_config")" nodered_credentials_file="${IOTEMPOWER_NODE_RED_CREDENTIALS_FILE:-$nodered_config_dir/iotempower-admin-credentials}" nodered_template="$IOTEMPOWER_LOCAL/nodejs/node_modules/node-red/settings.js" node_red_password_cli="$IOTEMPOWER_ROOT/bin/nodered_password_cli.js" -iotempower_auth_marker="IOTEMPOWER_NODE_RED_ADMIN_AUTH" iotempower_old_default_hash='$2b$08$W5LDP3eTaIYjz5iJkKVwMu9JDg3cPF' iotempower_old_default_hash+='MUvBypMCmYA3fpjYQlzFC4e' @@ -29,16 +28,21 @@ nodered_info() { fi } -nodered_active_settings_lines() { - grep -Ev '^[[:space:]]*(//|/\*|\*)' "$nodered_config" -} - nodered_has_js_setting() { local setting="$1" - nodered_active_settings_lines | grep -Eq "(^|;)[[:space:]]*([[:alpha:]_$][[:alnum:]_$]*)(\\.[[:alpha:]_$][[:alnum:]_$]*)*\\.${setting}[[:space:]]*=" \ - || nodered_active_settings_lines | grep -Eq "(^|;)[[:space:]]*([[:alpha:]_$][[:alnum:]_$]*)(\\.[[:alpha:]_$][[:alnum:]_$]*)*[[:space:]]*\\[[[:space:]]*['\"]${setting}['\"][[:space:]]*\\][[:space:]]*=" \ - || nodered_active_settings_lines | grep -Eq "(^|[,{])[[:space:]]*${setting}[[:space:]]*:" \ - || nodered_active_settings_lines | grep -Eq "(^|[,{])[[:space:]]*['\"]${setting}['\"][[:space:]]*:" + if [[ ! -f "$node_red_password_cli" ]]; then + echo "Cannot find Node-RED password helper at $node_red_password_cli." 1>&2 + exit 1 + fi + node "$node_red_password_cli" has-setting "$nodered_config" "$setting" +} + +nodered_has_managed_admin_auth() { + if [[ ! -f "$node_red_password_cli" ]]; then + echo "Cannot find Node-RED password helper at $node_red_password_cli." 1>&2 + exit 1 + fi + node "$node_red_password_cli" is-managed "$nodered_config" } nodered_has_admin_auth() { @@ -165,7 +169,7 @@ EOF fi fi -if grep -q "$iotempower_auth_marker" "$nodered_config"; then +if nodered_has_managed_admin_auth; then nodered_ensure_default_http_roots nodered_ensure_credential_files exit 0 diff --git a/bin/nodered_password_cli.js b/bin/nodered_password_cli.js index a63a8cb4..912fb583 100644 --- a/bin/nodered_password_cli.js +++ b/bin/nodered_password_cli.js @@ -7,6 +7,7 @@ const crypto = require("crypto"); const action = process.argv[2] || "status"; const marker = "IOTEMPOWER_NODE_RED_ADMIN_AUTH"; +const markerEnd = "IOTEMPOWER_NODE_RED_ADMIN_AUTH_END"; const oldDefaultHash = "$2b$08$W5LDP3eTaIYjz5iJkKVwMu9JDg3cPF" + "MUvBypMCmYA3fpjYQlzFC4e"; const username = "admin"; @@ -116,51 +117,181 @@ function loadBcrypt() { throw new Error(`Cannot load bcryptjs for IoTempower Node-RED adminAuth.${detail}`); } -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +function isIdentifierStart(char) { + return /[A-Za-z_$]/.test(char); } -function activeSettingsText(text) { - return text.split(/\r?\n/) - .filter((line) => !/^[ \t]*(?:\/\/|\/\*|\*)/.test(line)) - .join("\n"); +function isIdentifierPart(char) { + return /[A-Za-z0-9_$]/.test(char); } -const jsIdentifier = "[A-Za-z_$][A-Za-z0-9_$]*"; -const jsObjectPath = `${jsIdentifier}(?:\\.${jsIdentifier})*`; +function readQuotedToken(text, start) { + const quote = text[start]; + let index = start + 1; + let value = ""; + + while (index < text.length) { + const char = text[index]; + if (char === "\\") { + if (index + 1 < text.length) { + value += text[index + 1]; + index += 2; + continue; + } + index += 1; + break; + } + if (char === quote) { + index += 1; + break; + } + value += char; + index += 1; + } + + return { token: { type: "string", value }, index }; +} + +function tokenizeJsSettings(text) { + const tokens = []; + let index = 0; + + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + + if (/\s/.test(char)) { + index += 1; + continue; + } + + if (char === "/" && next === "/") { + index += 2; + while (index < text.length && !/[\r\n]/.test(text[index])) { + index += 1; + } + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) { + index += 1; + } + index = index < text.length ? index + 2 : index; + continue; + } + + if (char === "'" || char === "\"" || char === "`") { + const quoted = readQuotedToken(text, index); + tokens.push(quoted.token); + index = quoted.index; + continue; + } + + if (isIdentifierStart(char)) { + const start = index; + index += 1; + while (index < text.length && isIdentifierPart(text[index])) { + index += 1; + } + tokens.push({ type: "identifier", value: text.slice(start, index) }); + continue; + } + + tokens.push({ type: "punct", value: char }); + index += 1; + } + + return tokens; +} + +function tokenValue(tokens, index) { + return tokens[index] ? tokens[index].value : ""; +} + +function isSettingToken(token, setting) { + return token && + (token.type === "identifier" || token.type === "string") && + token.value === setting; +} + +function isObjectLiteralBoundary(value) { + return value === "{" || value === ","; +} + +function isDotAssignment(tokens, index, setting) { + return tokens[index] && + tokens[index].type === "identifier" && + tokens[index].value === setting && + tokenValue(tokens, index - 1) === "." && + tokenValue(tokens, index + 1) === "="; +} + +function isBracketAssignment(tokens, index, setting) { + return tokens[index] && + tokens[index].type === "string" && + tokens[index].value === setting && + tokenValue(tokens, index - 1) === "[" && + tokenValue(tokens, index + 1) === "]" && + tokenValue(tokens, index + 2) === "="; +} + +function isObjectProperty(tokens, index, setting) { + if (isSettingToken(tokens[index], setting) && + tokenValue(tokens, index + 1) === ":" && + isObjectLiteralBoundary(tokenValue(tokens, index - 1))) { + return true; + } + + return tokens[index] && + tokens[index].type === "string" && + tokens[index].value === setting && + tokenValue(tokens, index - 1) === "[" && + tokenValue(tokens, index + 1) === "]" && + tokenValue(tokens, index + 2) === ":" && + isObjectLiteralBoundary(tokenValue(tokens, index - 2)); +} function hasJsSetting(text, setting) { - const source = activeSettingsText(text); - const name = escapeRegExp(setting); - const patterns = [ - new RegExp(`(^|;)[ \\t]*${jsObjectPath}\\.${name}[ \\t]*=`, "m"), - new RegExp(`(^|;)[ \\t]*${jsObjectPath}[ \\t]*\\[[ \\t]*['"]${name}['"][ \\t]*\\][ \\t]*=`, "m"), - new RegExp(`(^|[,{])[ \\t]*${name}[ \\t]*:`, "m"), - new RegExp(`(^|[,{])[ \\t]*['"]${name}['"][ \\t]*:`, "m"), - ]; - return patterns.some((pattern) => pattern.test(source)); + const tokens = tokenizeJsSettings(text); + return tokens.some((token, index) => + isDotAssignment(tokens, index, setting) || + isBracketAssignment(tokens, index, setting) || + isObjectProperty(tokens, index, setting)); } function readStringSetting(text, setting, defaultValue) { - const source = activeSettingsText(text); - const name = escapeRegExp(setting); - const quotedValue = "(['\"`])([^'\"`]+)\\1"; - const patterns = [ - new RegExp(`(^|;)[ \\t]*${jsObjectPath}\\.${name}[ \\t]*=[ \\t]*${quotedValue}`, "m"), - new RegExp(`(^|;)[ \\t]*${jsObjectPath}[ \\t]*\\[[ \\t]*['"]${name}['"][ \\t]*\\][ \\t]*=[ \\t]*${quotedValue}`, "m"), - new RegExp(`(^|[,{])[ \\t]*${name}[ \\t]*:[ \\t]*${quotedValue}`, "m"), - new RegExp(`(^|[,{])[ \\t]*['"]${name}['"][ \\t]*:[ \\t]*${quotedValue}`, "m"), - ]; - - for (const pattern of patterns) { - const match = source.match(pattern); - if (match) { - return match[match.length - 1]; + const tokens = tokenizeJsSettings(text); + for (let index = 0; index < tokens.length; index += 1) { + if (isDotAssignment(tokens, index, setting) && tokens[index + 2] && tokens[index + 2].type === "string") { + return tokens[index + 2].value; + } + if (isBracketAssignment(tokens, index, setting) && tokens[index + 3] && tokens[index + 3].type === "string") { + return tokens[index + 3].value; + } + if (isObjectProperty(tokens, index, setting)) { + const valueIndex = tokenValue(tokens, index + 1) === ":" ? index + 2 : index + 3; + if (tokens[valueIndex] && tokens[valueIndex].type === "string") { + return tokens[valueIndex].value; + } } } return defaultValue; } +function hasManagedAuth(text) { + const start = text.indexOf(marker); + if (start === -1) { + return false; + } + const end = text.indexOf(markerEnd, start + marker.length); + if (end === -1) { + return false; + } + return hasJsSetting(text.slice(start, end + markerEnd.length), "adminAuth"); +} + function readSettingsInfo() { if (!exists(settingsFile)) { return { @@ -173,7 +304,7 @@ function readSettingsInfo() { const text = readText(settingsFile); return { exists: true, - managed: text.includes(marker), + managed: hasManagedAuth(text), customAdminAuth: hasJsSetting(text, "adminAuth"), legacyDefaultHash: text.includes(oldDefaultHash), }; @@ -357,7 +488,13 @@ function readStdin() { } try { - if (action === "status") { + if (action === "has-setting") { + const setting = process.argv[4]; + if (!setting) { + throw new Error("Missing setting name."); + } + process.exit(exists(settingsFile) && hasJsSetting(readText(settingsFile), setting) ? 0 : 1); + } else if (action === "status") { process.exit(printStatus()); } else if (action === "is-managed") { process.exit(readSettingsInfo().managed ? 0 : 1); diff --git a/tests/test_nodered_first_run_admin.py b/tests/test_nodered_first_run_admin.py index 844442f8..f51c0ba5 100644 --- a/tests/test_nodered_first_run_admin.py +++ b/tests/test_nodered_first_run_admin.py @@ -34,6 +34,14 @@ 'module.exports = {}; module.exports["adminAuth"] = { type: "credentials" };\n', id="same-line-bracket-assignment", ), + pytest.param( + 'module.exports = {\n ["adminAuth"]: { type: "credentials" }\n};\n', + id="computed-object-property", + ), + pytest.param( + "module.exports[`adminAuth`] = { type: \"credentials\" };\n", + id="template-bracket-assignment", + ), ] @@ -148,6 +156,7 @@ def test_managed_auth_adds_only_missing_default_http_root( "", "/* IOTEMPOWER_NODE_RED_ADMIN_AUTH */", "module.exports.adminAuth = {};", + "/* IOTEMPOWER_NODE_RED_ADMIN_AUTH_END */", "", ] ), @@ -161,3 +170,45 @@ def test_managed_auth_adds_only_missing_default_http_root( assert existing_root in settings_text assert expected_added in settings_text assert settings_text.count(existing_name) == 1 + + +def test_commented_admin_auth_and_roots_do_not_block_managed_auth(tmp_path): + settings_file = tmp_path / "settings.js" + settings_file.write_text( + "\n".join( + [ + "/*", + "adminAuth: { type: \"credentials\" },", + "httpAdminRoot: \"/commented-admin\",", + "*/", + "const note = \"{ httpNodeRoot: '/commented-node', adminAuth: true }\";", + "", + ] + ), + encoding="utf-8", + ) + + result = run_ensure_admin_auth(settings_file, iotempower_env(tmp_path)) + settings_text = settings_file.read_text(encoding="utf-8") + + assert result.returncode == 0, result.stderr + assert "module.exports.httpAdminRoot = '/nodered';" in settings_text + assert "module.exports.httpNodeRoot = '/nodered';" in settings_text + assert "module.exports.adminAuth = {" in settings_text + assert (tmp_path / "iotempower-admin-credentials").exists() + assert (tmp_path / "iotempower-admin-password.hash").exists() + + +def test_marker_comment_without_managed_block_is_not_treated_as_managed(tmp_path): + settings_file = tmp_path / "settings.js" + settings_file.write_text( + "/* IOTEMPOWER_NODE_RED_ADMIN_AUTH appears in an ordinary comment. */\n", + encoding="utf-8", + ) + + result = run_ensure_admin_auth(settings_file, iotempower_env(tmp_path)) + settings_text = settings_file.read_text(encoding="utf-8") + + assert result.returncode == 0, result.stderr + assert "module.exports.adminAuth = {" in settings_text + assert (tmp_path / "iotempower-admin-credentials").exists()