From 40bd25d9215c42b547ccde8561753702f5bf1da6 Mon Sep 17 00:00:00 2001 From: Cosimo Ricciardi Date: Thu, 14 May 2026 10:32:38 +0200 Subject: [PATCH] Address multi-node config auth preservation review --- backend/controllers/shared/RTLConf.js | 64 ++++++------ backend/utils/common.js | 18 ++-- server/controllers/shared/RTLConf.ts | 75 +++++++++++--- server/utils/common.ts | 18 ++-- test/backend/rtlconf.test.mjs | 139 ++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 56 deletions(-) create mode 100644 test/backend/rtlconf.test.mjs diff --git a/backend/controllers/shared/RTLConf.js b/backend/controllers/shared/RTLConf.js index dd1e386b..c474839b 100644 --- a/backend/controllers/shared/RTLConf.js +++ b/backend/controllers/shared/RTLConf.js @@ -218,9 +218,19 @@ export const updateNodeSettings = (req, res, next) => { fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); const selectedNode = common.findNode(req.session.selectedNode.index); if (selectedNode && selectedNode.settings) { - selectedNode.settings = req.body.settings; - selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; - selectedNode.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath; + selectedNode.settings = { ...selectedNode.settings, ...req.body.settings }; + if (req.body.authentication.boltzMacaroonPath) { + selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; + } + else { + delete selectedNode.authentication.boltzMacaroonPath; + } + if (req.body.authentication.swapMacaroonPath) { + selectedNode.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath; + } + else { + delete selectedNode.authentication.swapMacaroonPath; + } common.replaceNode(req, selectedNode); } let responseNode = JSON.parse(JSON.stringify(common.selectedNode)); @@ -239,18 +249,19 @@ export const updateApplicationSettings = (req, res, next) => { const RTLConfFile = common.appConfig.rtlConfFilePath + sep + 'RTL-Config.json'; try { const oldConfig = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); - const requestConfig = JSON.parse(JSON.stringify(req.body)); - const config = common.addSecureData(JSON.parse(JSON.stringify(requestConfig))); - const mergedConfig = JSON.parse(JSON.stringify(oldConfig)); + const config = common.addSecureData(JSON.parse(JSON.stringify(req.body))); + const runtimeConfig = JSON.parse(JSON.stringify(oldConfig)); Object.keys(config).forEach((key) => { if (key !== 'nodes') { - mergedConfig[key] = config[key]; + runtimeConfig[key] = config[key]; } }); - if (requestConfig.nodes && requestConfig.nodes.length > 0) { - const oldNodes = oldConfig.nodes || []; - mergedConfig.nodes = oldNodes.map((oldNode) => { - const newNode = requestConfig.nodes.find((node) => node.index === oldNode.index); + if (config.nodes && config.nodes.length > 0) { + const oldNodes = (common.appConfig.nodes && common.appConfig.nodes.length > 0) ? common.appConfig.nodes : (oldConfig.nodes || []); + const newNodesMap = new Map(config.nodes.map((node) => [node.index, node])); + const updatedAndExistingNodes = oldNodes.map((oldNode) => { + const newNode = newNodesMap.get(oldNode.index); + newNodesMap.delete(oldNode.index); const node = newNode ? { ...oldNode, ...newNode, @@ -261,26 +272,13 @@ export const updateApplicationSettings = (req, res, next) => { authentication: { ...(oldNode.authentication || {}) }, settings: { ...(oldNode.settings || {}) } }; - delete node.authentication?.options; - delete node.authentication?.runeValue; return node; }); - requestConfig.nodes.forEach((newNode) => { - if (!oldNodes.find((node) => node.index === newNode.index)) { - const node = JSON.parse(JSON.stringify(newNode)); - delete node.authentication?.options; - delete node.authentication?.runeValue; - mergedConfig.nodes.push(node); - } - }); + const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode))); + runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes]; } - delete mergedConfig.selectedNodeIndex; - delete mergedConfig.enable2FA; - delete mergedConfig.allowPasswordUpdate; - delete mergedConfig.rtlConfFilePath; - delete mergedConfig.rtlPass; common.appConfig = JSON.parse(JSON.stringify({ - ...mergedConfig, + ...runtimeConfig, selectedNodeIndex: config.selectedNodeIndex !== undefined ? config.selectedNodeIndex : common.appConfig.selectedNodeIndex, enable2FA: config.enable2FA !== undefined ? @@ -290,7 +288,17 @@ export const updateApplicationSettings = (req, res, next) => { rtlConfFilePath: common.appConfig.rtlConfFilePath, rtlPass: common.appConfig.rtlPass })); - fs.writeFileSync(RTLConfFile, JSON.stringify(mergedConfig, null, 2), 'utf-8'); + const fileConfig = JSON.parse(JSON.stringify(common.appConfig)); + delete fileConfig.selectedNodeIndex; + delete fileConfig.enable2FA; + delete fileConfig.allowPasswordUpdate; + delete fileConfig.rtlConfFilePath; + delete fileConfig.rtlPass; + fileConfig.nodes?.forEach((node) => { + delete node.authentication?.options; + delete node.authentication?.runeValue; + }); + fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); const newConfig = JSON.parse(JSON.stringify(common.appConfig)); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.maskPasswords(newConfig) }); res.status(201).json(common.removeSecureData(newConfig)); diff --git a/backend/utils/common.js b/backend/utils/common.js index e8ec856b..fea377ac 100644 --- a/backend/utils/common.js +++ b/backend/utils/common.js @@ -69,16 +69,18 @@ export class CommonService { if (config.secret2FA === this.appConfig.secret2FA) { config.secret2FA = this.appConfig.secret2FA; } - config.nodes.map((node, i) => { - if (this.appConfig && this.appConfig.nodes && this.appConfig.nodes.length > i && this.appConfig.nodes[i].authentication) { - if (this.appConfig.nodes[i].authentication.macaroonPath) { - node.authentication.macaroonPath = this.appConfig.nodes[i].authentication.macaroonPath; + const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node])); + config.nodes.map((node) => { + const appConfigNode = appConfigNodes.get(node.index); + if (appConfigNode?.authentication) { + if (appConfigNode.authentication.macaroonPath) { + node.authentication.macaroonPath = appConfigNode.authentication.macaroonPath; } - if (this.appConfig.nodes[i].authentication.runePath) { - node.authentication.runePath = this.appConfig.nodes[i].authentication.runePath; + if (appConfigNode.authentication.runePath) { + node.authentication.runePath = appConfigNode.authentication.runePath; } - if (this.appConfig.nodes[i].authentication.lnApiPassword) { - node.authentication.lnApiPassword = this.appConfig.nodes[i].authentication.lnApiPassword; + if (appConfigNode.authentication.lnApiPassword) { + node.authentication.lnApiPassword = appConfigNode.authentication.lnApiPassword; } } return node; diff --git a/server/controllers/shared/RTLConf.ts b/server/controllers/shared/RTLConf.ts index ab51798d..724d7a45 100644 --- a/server/controllers/shared/RTLConf.ts +++ b/server/controllers/shared/RTLConf.ts @@ -204,7 +204,7 @@ export const updateNodeSettings = (req, res, next) => { const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index)); if (node && node.settings) { - node.settings = req.body.settings; + node.settings = { ...node.settings, ...req.body.settings }; if (req.body.authentication.boltzMacaroonPath) { node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; } else { @@ -220,9 +220,17 @@ export const updateNodeSettings = (req, res, next) => { fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); const selectedNode = common.findNode(req.session.selectedNode.index); if (selectedNode && selectedNode.settings) { - selectedNode.settings = req.body.settings; - selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; - selectedNode.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath; + selectedNode.settings = { ...selectedNode.settings, ...req.body.settings }; + if (req.body.authentication.boltzMacaroonPath) { + selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; + } else { + delete selectedNode.authentication.boltzMacaroonPath; + } + if (req.body.authentication.swapMacaroonPath) { + selectedNode.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath; + } else { + delete selectedNode.authentication.swapMacaroonPath; + } common.replaceNode(req, selectedNode); } let responseNode = JSON.parse(JSON.stringify(common.selectedNode)); @@ -240,14 +248,57 @@ export const updateApplicationSettings = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Updating Application Settings..' }); const RTLConfFile = common.appConfig.rtlConfFilePath + sep + 'RTL-Config.json'; try { - const config = common.addSecureData(req.body); - common.appConfig = JSON.parse(JSON.stringify(config)); - delete config.selectedNodeIndex; - delete config.enable2FA; - delete config.allowPasswordUpdate; - delete config.rtlConfFilePath; - delete config.rtlPass; - fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); + const oldConfig = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); + const config = common.addSecureData(JSON.parse(JSON.stringify(req.body))); + const runtimeConfig = JSON.parse(JSON.stringify(oldConfig)); + Object.keys(config).forEach((key) => { + if (key !== 'nodes') { + runtimeConfig[key] = config[key]; + } + }); + if (config.nodes && config.nodes.length > 0) { + const oldNodes = (common.appConfig.nodes && common.appConfig.nodes.length > 0) ? common.appConfig.nodes : (oldConfig.nodes || []); + const newNodesMap = new Map(config.nodes.map((node) => [node.index, node])); + const updatedAndExistingNodes = oldNodes.map((oldNode) => { + const newNode = newNodesMap.get(oldNode.index); + newNodesMap.delete(oldNode.index); + const node = newNode ? { + ...oldNode, + ...newNode, + authentication: { ...(oldNode.authentication || {}), ...(newNode.authentication || {}) }, + settings: { ...(oldNode.settings || {}), ...(newNode.settings || {}) } + } : { + ...oldNode, + authentication: { ...(oldNode.authentication || {}) }, + settings: { ...(oldNode.settings || {}) } + }; + return node; + }); + const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode))); + runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes]; + } + common.appConfig = JSON.parse(JSON.stringify({ + ...runtimeConfig, + selectedNodeIndex: config.selectedNodeIndex !== undefined ? + config.selectedNodeIndex : common.appConfig.selectedNodeIndex, + enable2FA: config.enable2FA !== undefined ? + config.enable2FA : common.appConfig.enable2FA, + allowPasswordUpdate: config.allowPasswordUpdate !== undefined ? + config.allowPasswordUpdate : common.appConfig.allowPasswordUpdate, + rtlConfFilePath: common.appConfig.rtlConfFilePath, + rtlPass: common.appConfig.rtlPass + })); + const fileConfig = JSON.parse(JSON.stringify(common.appConfig)); + delete fileConfig.selectedNodeIndex; + delete fileConfig.enable2FA; + delete fileConfig.allowPasswordUpdate; + delete fileConfig.rtlConfFilePath; + delete fileConfig.rtlPass; + fileConfig.nodes?.forEach((node) => { + delete node.authentication?.options; + delete node.authentication?.runeValue; + }); + fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); const newConfig = JSON.parse(JSON.stringify(common.appConfig)); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.maskPasswords(newConfig) }); res.status(201).json(common.removeSecureData(newConfig)); diff --git a/server/utils/common.ts b/server/utils/common.ts index 0314ed7a..d3418bf5 100644 --- a/server/utils/common.ts +++ b/server/utils/common.ts @@ -78,16 +78,18 @@ export class CommonService { if (config.secret2FA === this.appConfig.secret2FA) { config.secret2FA = this.appConfig.secret2FA; } - config.nodes.map((node, i) => { - if (this.appConfig && this.appConfig.nodes && this.appConfig.nodes.length > i && this.appConfig.nodes[i].authentication) { - if (this.appConfig.nodes[i].authentication.macaroonPath) { - node.authentication.macaroonPath = this.appConfig.nodes[i].authentication.macaroonPath; + const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node])); + config.nodes.map((node) => { + const appConfigNode = appConfigNodes.get(node.index); + if (appConfigNode?.authentication) { + if (appConfigNode.authentication.macaroonPath) { + node.authentication.macaroonPath = appConfigNode.authentication.macaroonPath; } - if (this.appConfig.nodes[i].authentication.runePath) { - node.authentication.runePath = this.appConfig.nodes[i].authentication.runePath; + if (appConfigNode.authentication.runePath) { + node.authentication.runePath = appConfigNode.authentication.runePath; } - if (this.appConfig.nodes[i].authentication.lnApiPassword) { - node.authentication.lnApiPassword = this.appConfig.nodes[i].authentication.lnApiPassword; + if (appConfigNode.authentication.lnApiPassword) { + node.authentication.lnApiPassword = appConfigNode.authentication.lnApiPassword; } } return node; diff --git a/test/backend/rtlconf.test.mjs b/test/backend/rtlconf.test.mjs new file mode 100644 index 00000000..05546949 --- /dev/null +++ b/test/backend/rtlconf.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { updateApplicationSettings } from '../../backend/controllers/shared/RTLConf.js'; +import { Common } from '../../backend/utils/common.js'; +import { WSServer } from '../../backend/utils/webSocketServer.js'; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +test('updateApplicationSettings preserves indexed node auth and sanitizes only persisted config', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-')); + const oldConfig = { + defaultNodeIndex: 0, + dbDirectoryPath: '/db', + SSO: { rtlSSO: 0, rtlCookiePath: '/cookie', logoutRedirectLink: '', cookieValue: '' }, + nodes: [ + { + index: 0, + lnNode: 'lnd-main', + lnImplementation: 'LND', + authentication: { macaroonPath: '/lnd/admin' }, + settings: { userPersona: 'OPERATOR', themeMode: 'DAY' } + }, + { + index: 2, + lnNode: 'cln-secondary', + lnImplementation: 'CLN', + authentication: { runePath: '/cln/rune' }, + settings: { userPersona: 'MERCHANT', themeMode: 'NIGHT', blockExplorerUrl: 'https://old.example' } + } + ] + }; + const runtimeConfig = clone({ + ...oldConfig, + selectedNodeIndex: 2, + enable2FA: true, + allowPasswordUpdate: true, + rtlConfFilePath: tempDir, + rtlPass: 'hashed-password', + multiPassHashed: 'multi-pass-hash', + nodes: [ + { + ...oldConfig.nodes[0], + authentication: { + ...oldConfig.nodes[0].authentication, + options: { headers: { 'Grpc-Metadata-macaroon': 'runtime-lnd-macaroon' } } + } + }, + { + ...oldConfig.nodes[1], + authentication: { + ...oldConfig.nodes[1].authentication, + runeValue: 'runtime-rune', + options: { headers: { rune: 'runtime-rune' } } + } + } + ] + }); + const requestBody = { + defaultNodeIndex: 0, + selectedNodeIndex: 2, + enable2FA: false, + allowPasswordUpdate: false, + dbDirectoryPath: '/db-updated', + secret2FA: '', + SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }, + nodes: [ + { + index: 2, + lnNode: 'cln-secondary', + lnImplementation: 'CLN', + authentication: { swapMacaroonPath: '/loop/cln' }, + settings: { themeMode: 'DAY' } + }, + { + index: 5, + lnNode: 'new-lnd', + lnImplementation: 'LND', + authentication: { macaroonPath: '/new-lnd/admin' }, + settings: { userPersona: 'OPERATOR' } + } + ] + }; + + try { + Common.appConfig = clone(runtimeConfig); + Common.nodes = clone(runtimeConfig.nodes); + Common.selectedNode = Common.nodes[1]; + writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8'); + + let responseStatus; + let responseBody; + updateApplicationSettings( + { body: clone(requestBody), session: { selectedNode: Common.selectedNode } }, + { + status: (status) => { + responseStatus = status; + return { + json: (body) => { + responseBody = body; + } + }; + } + }, + null + ); + + assert.equal(responseStatus, 201); + assert.deepEqual(Common.appConfig.nodes.map((node) => node.index), [0, 2, 5]); + + const runtimeClnNode = Common.appConfig.nodes[1]; + assert.equal(runtimeClnNode.authentication.runePath, '/cln/rune'); + assert.equal(runtimeClnNode.authentication.macaroonPath, undefined); + assert.equal(runtimeClnNode.authentication.runeValue, 'runtime-rune'); + assert.deepEqual(runtimeClnNode.authentication.options, { headers: { rune: 'runtime-rune' } }); + assert.equal(runtimeClnNode.authentication.swapMacaroonPath, '/loop/cln'); + assert.equal(runtimeClnNode.settings.themeMode, 'DAY'); + assert.equal(runtimeClnNode.settings.blockExplorerUrl, 'https://old.example'); + + const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8')); + assert.deepEqual(fileConfig.nodes.map((node) => node.index), [0, 2, 5]); + assert.equal(fileConfig.rtlPass, undefined); + assert.equal(fileConfig.rtlConfFilePath, undefined); + assert.equal(fileConfig.selectedNodeIndex, undefined); + assert.equal(fileConfig.enable2FA, undefined); + assert.equal(fileConfig.allowPasswordUpdate, undefined); + assert.equal(fileConfig.nodes[1].authentication.runePath, '/cln/rune'); + assert.equal(fileConfig.nodes[1].authentication.macaroonPath, undefined); + assert.equal(fileConfig.nodes[1].authentication.runeValue, undefined); + assert.equal(fileConfig.nodes[1].authentication.options, undefined); + assert.equal(responseBody.nodes[1].authentication.runePath, undefined); + } finally { + clearInterval(WSServer.pingInterval); + rmSync(tempDir, { force: true, recursive: true }); + } +});