Address multi-node config auth preservation review

This commit is contained in:
Cosimo Ricciardi 2026-05-14 10:32:38 +02:00 committed by Suheb
parent fa65568914
commit 40bd25d921
5 changed files with 258 additions and 56 deletions

View file

@ -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));

View file

@ -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;

View file

@ -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));

View file

@ -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;

View file

@ -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 });
}
});