import jwt from 'jsonwebtoken'; import * as fs from 'fs'; import { resolve, sep } from 'path'; import ini from 'ini'; import parseHocon from 'hocon-parser'; import request from '../../utils/request.js'; import { Database } from '../../utils/database.js'; import { Logger } from '../../utils/logger.js'; import { Common } from '../../utils/common.js'; import { WSServer } from '../../utils/webSocketServer.js'; import { Authentication } from '../../models/config.model.js'; const options = { url: '' }; const logger = Logger; const common = Common; const wsServer = WSServer; const databaseService = Database; // Set local block explorer URL after first API call // if the selected node block explorer has working REST API suite // otherwise set it to mempool.space let blockExplorerUrl = ''; export const getExplorerFeesRecommended = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting Recommended Fee Rates..' }); options.url = (blockExplorerUrl === '') ? req.session.selectedNode.settings.blockExplorerUrl + '/api/v1/fees/recommended' : blockExplorerUrl + '/api/v1/fees/recommended'; request(options).then((body) => { blockExplorerUrl = req.session.selectedNode.settings.blockExplorerUrl; logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Recommended Fee Rates Received', data: body }); res.status(200).json(JSON.parse(body)); }).catch((errRes) => { blockExplorerUrl = 'https://mempool.space'; options.url = blockExplorerUrl + '/api/v1/fees/recommended'; return request(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Recommended Fee Rates Received', data: body }); res.status(200).json(JSON.parse(body)); }).catch((errRes) => { const errMsg = 'Get Recommended Fee Rates Error'; const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); return res.status(err.statusCode).json({ message: err.error, error: err.error }); }); }); }; export const getExplorerTransaction = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting Transaction From Block Explorer..' }); options.url = (blockExplorerUrl === '') ? req.session.selectedNode.settings.blockExplorerUrl + '/api/tx/' + req.params.txid : blockExplorerUrl + '/api/tx/' + req.params.txid; request(options).then((body) => { blockExplorerUrl = req.session.selectedNode.settings.blockExplorerUrl; logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Transaction From Block Explorer Received', data: body }); res.status(200).json(JSON.parse(body)); }).catch((errRes) => { blockExplorerUrl = 'https://mempool.space'; options.url = blockExplorerUrl + '/api/tx/' + req.params.txid; return request(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Transaction From Block Explorer Received', data: body }); res.status(200).json(JSON.parse(body)); }).catch((errRes) => { const errMsg = 'Get Transaction From Block Explorer Error'; const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); return res.status(err.statusCode).json({ message: err.error, error: err.error }); }); }); }; export const getCurrencyRates = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting Currency Rates..' }); options.url = 'https://blockchain.info/ticker'; request(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Currency Rates Received', data: body }); res.status(200).json(JSON.parse(body)); }).catch((errRes) => { const errMsg = 'Get Rates Error'; const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); return res.status(err.statusCode).json({ message: err.error, error: err.error }); }); }; export const getFile = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' }); const channelBackupPath = req.session.selectedNode.settings.channelBackupPath; let file = ''; if (req.query.path) { // The UI only ever requests channel backup files; contain caller paths to the node's // backup directory so this endpoint cannot read the config, macaroons or the SSO // cookie (getConfig serves the config file masked; this must not bypass that). const resolved = resolve(req.query.path); if (resolved !== resolve(channelBackupPath) && !resolved.startsWith(resolve(channelBackupPath) + sep)) { logger.log({ selectedNode: req.session.selectedNode, level: 'WARN', fileName: 'RTLConf', msg: 'Blocked file read outside the channel backup directory', data: req.query.path }); const err = common.handleError({ statusCode: 403, message: 'Reading File Error', error: 'File path is outside the channel backup directory' }, 'RTLConf', 'Reading File Error', req.session.selectedNode); return res.status(err.statusCode).json({ message: err.message, error: err.error }); } file = resolved; } else { file = channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak'; } logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'Channel Point', data: req.query.channel }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file }); fs.readFile(file, 'utf8', (errRes, data) => { if (errRes) { if (errRes.code && errRes.code === 'ENOENT') { errRes.code = 'File Not Found!'; } const errMsg = 'Reading File Error'; const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); return res.status(err.statusCode).json({ message: err.error, error: err.error }); } else { // File contents can carry node credentials; never write them to the log. logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received' }); res.status(200).json(data); } }); }; export const getApplicationSettings = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting RTL Configuration..' }); const appConfData = common.removeSecureData(JSON.parse(JSON.stringify(common.appConfig))); appConfData.allowPasswordUpdate = common.appConfig.allowPasswordUpdate; appConfData.enable2FA = common.appConfig.enable2FA; appConfData.selectedNodeIndex = (req.session.selectedNode && req.session.selectedNode.index ? req.session.selectedNode.index : common.selectedNode.index); common.appConfig.selectedNodeIndex = appConfData.selectedNodeIndex; const token = req.headers.authorization ? req.headers.authorization.split(' ')[1] : ''; jwt.verify(token, common.secret_key, (err, user) => { if (err) { // Delete unnecessary data for initial response (without security token) const selNodeIdx = appConfData.nodes.findIndex((node) => node.index === appConfData.selectedNodeIndex) || 0; delete appConfData.SSO.rtlCookiePath; delete appConfData.SSO.cookieValue; delete appConfData.SSO.logoutRedirectLink; appConfData.dbDirectoryPath = ''; appConfData.nodes[selNodeIdx].authentication = new Authentication(); delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath; delete appConfData.nodes[selNodeIdx].settings.lnServerUrl; delete appConfData.nodes[selNodeIdx].settings.swapServerUrl; delete appConfData.nodes[selNodeIdx].settings.boltzServerUrl; delete appConfData.nodes[selNodeIdx].settings.enableOffers; delete appConfData.nodes[selNodeIdx].settings.enablePeerswap; delete appConfData.nodes[selNodeIdx].settings.channelBackupPath; appConfData.nodes = [appConfData.nodes[selNodeIdx]]; } logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'RTL Configuration Received', data: appConfData }); res.status(200).json(appConfData); }); }; export const updateSelectedNode = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Updating Selected Node..' }); const selNodeIndex = req.params.currNodeIndex ? +req.params.currNodeIndex : common.selectedNode ? +common.selectedNode.index : 1; req.session.selectedNode = common.findNode(selNodeIndex); common.selectedNode = req.session.selectedNode; if (req.headers && req.headers.authorization && req.headers.authorization !== '') { wsServer.updateLNWSClientDetails(req.session.id, +req.session.selectedNode.index, +req.params.prevNodeIndex); if (req.params.prevNodeIndex !== '-1') { databaseService.unloadDatabase(req.params.prevNodeIndex, req.session.id); } if (req.params.currNodeIndex !== '-1') { databaseService.loadDatabase(req.session); } } blockExplorerUrl = ''; logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Selected Node Updated To ' + req.session.selectedNode.lnNode || '' }); res.status(200).json(common.removeAuthSecureData(JSON.parse(JSON.stringify(req.session.selectedNode)))); }; export const getConfig = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Reading Configuration File..' }); let confFile = ''; let fileFormat = 'INI'; switch (req.params.nodeType) { case 'ln': confFile = req.session.selectedNode.authentication.configPath; break; case 'bitcoind': confFile = req.session.selectedNode.settings.bitcoindConfigPath; break; case 'rtl': fileFormat = 'JSON'; confFile = common.appConfig.rtlConfFilePath + sep + 'RTL-Config.json'; break; default: confFile = ''; break; } logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'Node Type', data: req.params.nodeType }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: confFile }); fs.readFile(confFile, 'utf8', (errRes, data) => { if (errRes) { const errMsg = 'Reading Config Error'; const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); return res.status(err.statusCode).json({ message: err.error, error: err.error }); } else { let jsonConfig = {}; if (fileFormat === 'JSON') { jsonConfig = JSON.parse(data); } else { fileFormat = 'INI'; data = data?.replace('color=#', 'color='); jsonConfig = ini.parse(data); if (jsonConfig['Application Options'] && jsonConfig['Application Options'].color) { jsonConfig['Application Options'].color = '#' + jsonConfig['Application Options'].color; } if (req.params.nodeType === 'ln' && req.session.selectedNode.lnImplementation === 'ECL' && !jsonConfig['eclair.api.password']) { fileFormat = 'HOCON'; jsonConfig = parseHocon(data); } } jsonConfig = common.maskPasswords(jsonConfig); const responseJSON = (fileFormat === 'JSON') ? jsonConfig : ini.stringify(jsonConfig)?.replace('color=\\#', 'color=#'); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Configuration File Data Received', data: responseJSON }); res.status(200).json({ format: fileFormat, data: responseJSON }); } }); }; export const updateNodeSettings = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Updating Node Settings..' }); const RTLConfFile = common.appConfig.rtlConfFilePath + sep + 'RTL-Config.json'; try { 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) { // channelBackupPath anchors getFile's containment root and is documented as a // config-file-only setting; accepting it from the API would let the caller being // contained choose the containment base. Pin it to the server-held value. const serverChannelBackupPath = node.settings.channelBackupPath; node.settings = { ...node.settings, ...req.body.settings }; node.settings.channelBackupPath = serverChannelBackupPath; if (node.authentication && req.body.authentication) { if (req.body.authentication.boltzMacaroonPath) { node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; } else { delete node.authentication.boltzMacaroonPath; } if (req.body.authentication.swapMacaroonPath) { node.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath; } else { delete node.authentication.swapMacaroonPath; } } } fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); const selectedNode = common.findNode(req.session.selectedNode.index); if (selectedNode && selectedNode.settings) { const serverChannelBackupPath = selectedNode.settings.channelBackupPath; selectedNode.settings = { ...selectedNode.settings, ...req.body.settings }; selectedNode.settings.channelBackupPath = serverChannelBackupPath; if (selectedNode.authentication && req.body.authentication) { 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)); responseNode = common.removeAuthSecureData(responseNode); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Node Settings Updated', data: responseNode }); res.status(201).json(responseNode); } catch (errRes) { const errMsg = 'Update Node Settings Error'; const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); return res.status(err.statusCode).json({ message: err.error, error: err.error }); } }; 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 oldConfig = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); const config = common.addSecureData(JSON.parse(JSON.stringify(req.body))); const runtimeConfig = 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]; } const newAppConfig = 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(newAppConfig)); delete fileConfig.selectedNodeIndex; delete fileConfig.enable2FA; delete fileConfig.allowPasswordUpdate; delete fileConfig.rtlConfFilePath; delete fileConfig.rtlPass; delete fileConfig.multiPass; // Runtime-only SSO bearer; must not be persisted with the config. if (fileConfig.SSO) { delete fileConfig.SSO.cookieValue; } fileConfig.nodes?.forEach((node) => { delete node.authentication?.options; delete node.authentication?.runeValue; }); // Persist atomically (temp file + rename, so a mid-write failure cannot truncate the // config) and only then adopt the new runtime config, so a failed write leaves the // process on the old one. The temp file inherits the existing file's mode so a // hardened 0600 is not silently downgraded; a fresh file gets 0600. Symlinks and // single-file bind mounts cannot be renamed over — fall back to an in-place write, // which preserves inode and mode. const tempConfigFile = RTLConfFile + '.tmp'; try { fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); fs.chmodSync(tempConfigFile, fs.existsSync(RTLConfFile) ? (fs.statSync(RTLConfFile).mode & 0o777) : 0o600); fs.renameSync(tempConfigFile, RTLConfFile); } catch { fs.rmSync(tempConfigFile, { force: true, recursive: true }); fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); } common.appConfig = newAppConfig; // removeSecureData clones, so the runtime config is untouched; it strips rtlPass, // the TOTP seed, the SSO cookie and all per-node credentials symmetrically. logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.removeSecureData(newAppConfig) }); res.status(201).json(common.removeSecureData(newAppConfig)); } catch (errRes) { const errMsg = 'Update Default Node Error'; const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); return res.status(err.statusCode).json({ message: err.error, error: err.error }); } };