diff --git a/modules/nodered/client.ts b/modules/nodered/client.ts index 0e8bf7c..0cb05fc 100644 --- a/modules/nodered/client.ts +++ b/modules/nodered/client.ts @@ -3,7 +3,8 @@ // // Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow // configuration, GET /nodes for installed node modules. A default install has no auth; when -// adminAuth is on, a bearer token (minted at /auth/token) is required. +// adminAuth is on, a bearer token is required — the module's settings accept the mesh-minted +// api-token, which the runtime config file carries as `token`. import { readFileSync } from "node:fs"; @@ -88,8 +89,13 @@ export class NodeRedClient { async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> { const body = await this.req("/flows", { method: "POST", - headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }), - body: JSON.stringify(config), + // v2 answers { rev }; v1 answers 204 with no body, which req() cannot parse. + headers: this.headers({ + "Content-Type": "application/json", + "Node-RED-API-Version": "v2", + "Node-RED-Deployment-Type": type, + }), + body: JSON.stringify({ flows: config }), }); return { rev: body?.rev, nodeCount: config.length }; } diff --git a/modules/nodered/module.json b/modules/nodered/module.json index 181edf8..f42dffb 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -5,6 +5,8 @@ "flows.deployed" ], "own-secrets": { + "admin": "/var/lib/mesh/nodered/admin", + "api-token": "/var/lib/mesh/nodered/api-token", "broker": "/var/lib/mesh/nodered/broker" }, "capabilities": [ @@ -26,26 +28,58 @@ "path": "/var/lib/mesh/nodered", "mode": "0700" }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, { "id": "data", "type": "directory", - "path": "/services/nodered/data", "mode": "0700", "owner": "1000:1000" }, + { + "id": "settings-code", + "type": "file", + "path": "${dir:state}/settings.js", + "mode": "0600", + "owner": "1000:1000", + "content": "// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change\n// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and\n// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user\n// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this.\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst ADMIN_PASSWORD = \"${secret:admin}\";\nconst API_TOKEN = \"${secret:api-token}\";\nconst ADMIN = { username: \"admin\", permissions: \"*\" };\n\nconst settings = JSON.parse(fs.readFileSync(path.join(__dirname, \"settings.json\"), \"utf8\"));\n// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the\n// assignment's way to set the zone flows schedule and format in.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\n\nfunction same(a, b) {\n const x = crypto.createHash(\"sha256\").update(String(a)).digest();\n const y = crypto.createHash(\"sha256\").update(String(b)).digest();\n return crypto.timingSafeEqual(x, y);\n}\n\n// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its\n// settings held, so the password people already use keeps working.\nfunction passwordMatches(given) {\n if (/^\\$2[aby]\\$\\d\\d\\$/.test(ADMIN_PASSWORD)) return require(\"bcryptjs\").compare(String(given), ADMIN_PASSWORD);\n return Promise.resolve(same(given, ADMIN_PASSWORD));\n}\n\nmodule.exports = Object.assign(settings, {\n uiPort: 1880,\n adminAuth: {\n type: \"credentials\",\n users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null),\n authenticate: (username, password) =>\n username === ADMIN.username\n ? passwordMatches(password).then((ok) => (ok ? ADMIN : null))\n : Promise.resolve(null),\n // The module's own tools call the admin API with this bearer token.\n tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: \"mesh\", permissions: \"*\" } : null),\n },\n});\n" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "owner": "1000:1000", + "merge": "json", + "content": "{\n \"flowFile\": \"flows.json\",\n \"flowFilePretty\": true,\n \"diagnostics\": { \"enabled\": true, \"ui\": true },\n \"runtimeState\": { \"enabled\": false, \"ui\": false },\n \"logging\": { \"console\": { \"level\": \"info\", \"metrics\": false, \"audit\": false } },\n \"exportGlobalContextKeys\": false,\n \"externalModules\": {},\n \"editorTheme\": { \"projects\": { \"enabled\": false } },\n \"functionExternalModules\": true,\n \"debugMaxLength\": 1000,\n \"mqttReconnectTime\": 15000,\n \"serialReconnectTime\": 15000\n}\n" + }, { "id": "server", "type": "container", "name": "nodered", - "image": "nodered/node-red@sha256:02a2b92a41b73d2bc388238b86e4fcaab7fb5466373adb24e1df6aa5845265ff", + "image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace", "env": { "TZ": "Etc/UTC" }, "ports": [ "1880" ], + "args": [ + "--settings", + "/config/settings.js" + ], "volumes": [ - "/services/nodered/data:/data" + "${dir:data}:/data", + "${dir:state}/settings.js:/config/settings.js:ro", + "${dir:state}/settings.json:/config/settings.json:ro" + ], + "restart-on": [ + "settings-code", + "settings" ] }, { @@ -53,8 +87,7 @@ "type": "file", "path": "/var/lib/mesh/nodered/config.json", "mode": "0600", - "content": "{}\n", - "merge": "json" + "content": "{\n \"token\": \"${secret:api-token}\"\n}\n" }, { "id": "runtime", @@ -86,7 +119,7 @@ } }, "binds": { - "route": "/var/lib/mesh/nodered/route.json" + "route": "${dir:state}/route.json" }, "build": { "on": [