Compare commits

...

4 commits

Author SHA1 Message Date
Suheb
d4e2554ca4
Add a BTCPay Server SSO harness to the docker fixture (#1669)
* Add a BTCPay Server SSO harness to the docker fixture

BTCPay bundles RTL and runs it in single-sign-on mode, reached over an entry
path the standalone login never exercises: no password, a rotating cookie file,
an unregistered /rtl/api/authenticate/cookie URL that falls through to the
catch-all in server/utils/app.ts, and a reverse proxy in front. Regressions on
that path have previously gone unnoticed until they reached BTCPay users.

Adds an "sso" compose profile, so a plain `docker compose up -d` is unchanged:

  - rtl-sso, a second RTL running with RTL_SSO=1, RTL_COOKIE_PATH and
    LOGOUT_REDIRECT_LINK -- the environment block lifted verbatim from BTCPay's
    own compose fragment, so this exercises the env-driven SSO path BTCPay
    actually uses. A second container is required because RTL selects one
    authentication mode at startup, so SSO and password login cannot coexist in
    one instance.
  - rtl-sso-config-init, staging rtl/RTL-Config.sso.json into a volume -- the
    same copy-into-a-volume dance the standalone RTL already needs, because RTL
    rewrites its config on startup.
  - rtl-sso-proxy, nginx standing in for BTCPay's traefik, routing only /rtl
    and /rtl/* exactly as BTCPay's router rule does. There is no prefix
    stripping anywhere: RTL is built with <base href="/rtl/"> and mounts every
    route under baseHref '/rtl', so the prefix is passed through unmodified.
    Everything outside /rtl 404s, so a request escaping the prefix surfaces as
    a failure rather than being quietly served.

scripts/verify-sso.sh asserts the whole flow in 11 checks -- prefix routing,
CSRF token minting on the catch-all, the sha256 access-key handshake, an
authenticated node call, cookie rotation on login, and rejection of a wrong key
-- and exits non-zero so it can gate a change. bin/sso-url prints the link
BTCPay renders on its Services page. RTL_IMAGE overrides both RTL containers at
once, so a branch build gets tested through both entry paths.

BTCPay itself (postgres, nbxplorer, btcpayserver) is deliberately not included;
the README documents what that leaves untested and how to run against BTCPay's
own regtest stack when the question is BTCPay's behaviour rather than RTL's.

Also bumps the fixture's default RTL image from v0.15.8 to v0.15.10.

* Document the SSO harness in the rtl-docker-fixture skill

* Point CLAUDE.md at the BTCPay SSO harness

* Note that no CI runs on an open PR
2026-08-04 18:17:19 -07:00
Suheb
a005b687a7
Release 0.15.10 (#1665)
* Update version 0.15.10

* Update project dependencies to resolve Dependabot security alerts

Applies the fixes from the open Dependabot PRs (#1648, #1649, #1650) in a
single pass on the release branch, regenerating the lockfile from scratch.

axios 1.16.0 -> 1.18.1 was the only production exposure (10 advisories).
Transitive deps moved to their fixed in-range versions (fast-uri 3.1.4,
form-data, qs, tough-cookie, tar, del, globby); dev toolchain took safe
bumps (nodemon 3.1.14, eslint 9.39.5, @typescript-eslint 8.65.0).

Drops the unused protractor devDependency: no e2e directory, no config and
no e2e target in angular.json, but 100 packages and the deprecated request
stack behind it. That clears both critical advisories.

npm audit: 50 (2 critical) -> 29 (0 critical); production deps 1 -> 0.
Remaining findings are dev-only tooling needing an Angular 21 migration
rather than a version bump.

Verified: lint, 204 frontend specs, backend + frontend production builds,
and 19 API checks against the docker regtest fixture covering LND, Core
Lightning and Eclair (getinfo, channels, peers, invoices, payments and
forwarding history).

* Fill in PR number in release note (#1653)

* Harden login request validation (#1654)

Tightens server-side validation of authentication requests, guards the password-reset route behind an authenticated session, and wires the backend regression suite (test/backend/) into npm run test. Users with two-factor authentication enabled are encouraged to update promptly.

Verified: backend specs 12/12, lint green, frontend specs 204/204, and the full authentication matrix end-to-end on the docker regtest fixture.

* Reduce exposure of authentication secrets in logs and config responses (#1659)

* Reduce exposure of authentication secrets in logs and config responses

* Fill in PR number in release note (#1659)

* Harden redaction helpers and secret restore paths

* Pin deployment auth switches server-side and harden settings persistence

* Contain backup file reads and harden config persistence

* Pin backup containment root and preserve config file mode on save

* Update Angular framework packages to 20.3.27 (#1661)

* Update Angular framework packages to 20.3.27

Batches the three Dependabot PRs open against master for the Angular framework
(@angular/core #1658, @angular/compiler #1657, @angular/common #1655) into one
update on the release branch. The framework packages are pinned to exact
versions and their peer ranges require them to move together, so all nine
20.3.26 packages go to 20.3.27: animations, common, compiler, compiler-cli,
core, forms, platform-browser, platform-browser-dynamic and router.

Patch-level upstream fixes only, no advisories. The update stays inside Angular
20 - @angular/build and @angular/cli (20.3.32) and @angular/cdk/@angular/material
(20.2.14) are already at the top of their v20 lines - so it does not pull in the
Angular 21 migration tracked by #1650.

Rebuilt frontend/ for the new framework code. backend/ is unchanged, as no
server/ source moved.

* Fill in PR number in release note (#1661)

* Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts   Fixes #1630 (#1651)

* Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts

Fixes #1630

* Address review feedback: fix options race, error handling, release notes

* Improve release notes entry to cover full PR scope

* Address review feedback: per-task options copy, exclude qs from alias requests

* Stop logging the eclair auth header at DEBUG level (#1664)

* Stop logging the eclair auth header at DEBUG level

getChannels in the eclair channels controller logged its whole request
options object. Eclair authenticates with HTTP basic auth, so those
options carry the configured lnApiPassword in an authorization header -
raising an eclair node's logLevel to DEBUG wrote
"authorization":"Basic <base64>" into the node log file, which is a
recoverable form of the credential and is routinely shared when
debugging.

The log now carries only the request url and form, matching every other
DEBUG log in the controllers. This was the only site in server/ passing a
whole options object to the logger; the rest log options.form, .url,
.body or .qs, none of which hold credentials.

Present since 0.12.0 and only reachable by opting in to DEBUG (the
default log level is ERROR), but it contradicted the logging guarantee
stated for #1659.

Found by scanning node logs at DEBUG while verifying the 0.15.10 branch
against the regtest fixture. Regression test added in
test/backend/eclair-channels.test.mjs; it fails on the previous code with
"auth header key must not reach the node log".

* Fill in PR number in release note (#1664)

---------

Co-authored-by: Osuji <weezdomosuji@gmail.com>
2026-08-03 22:49:14 -07:00
saubyk
f48a647272
Add CLAUDE.md with agent-facing notes on the codebase
Covers the things that are easy to get wrong and are not obvious from the
tree: that frontend/ and backend/ are committed build output that must be
regenerated rather than hand-edited, the parallel lnd/cln/eclair/shared
layout, the install and dev-server flags that differ from the defaults,
and the release-branch flow including how to recover a PR left open across
a release cut.

Process itself stays in CONTRIBUTING.md; this file points at it rather
than restating it.
2026-07-28 20:59:58 -07:00
saubyk
9cc86b2d4c Add rtl-docker-fixture Claude Code skill
Moves the docker/ regtest fixture instructions out of the always-loaded
CLAUDE.md into an on-demand skill, so they load only when someone is actually
working with the fixture.

Keeps the details docker/README.md does not cover: `docker compose up -d rtl`
silently restarts stopped dependencies (reconnecting a peer you stopped
mid-test), and the API handshake for verification scripts — base href /rtl,
CSRF token echoed as x-xsrf-token, SHA256-hashed password, and cln/getinfo
before any CLN channel endpoint.
2026-07-25 10:13:59 -07:00
35 changed files with 2503 additions and 1595 deletions

View file

@ -0,0 +1,98 @@
---
name: rtl-docker-fixture
description: Bring up and use the docker/ regtest fixture (bitcoind + LND alice/bob/carol + Core Lightning + Eclair + RTL) to test RTL end-to-end. Use when testing a branch against real Lightning nodes, seeding channels and payments, driving RTL's API for verification, taking screenshots of live data, or reproducing disconnected-peer states.
---
# Testing against a live network — `docker/` regtest fixture
`docker/` is a self-contained regtest network for developing and testing RTL end-to-end:
`bitcoind` + three LND nodes (**alice → bob → carol**) + a **Core Lightning node** (`cln`,
with a channel to alice) + RTL wired to all four. bob sits in the middle so it accrues
forwarding history and RTL's routing screens have data; the CLN node gives RTL's Core
Lightning screens a real backend (it talks to RTL over clnrest with rune auth). LND/bitcoind
images come from [Polar](https://lightningpolar.com), CLN from `elementsproject/lightningd`
(all multi-arch, so it works on Apple Silicon). **Dev only; every credential is throwaway.**
Full details in `docker/README.md`.
Bring it up (from `docker/`, needs Compose v2 — `docker compose`, not `docker-compose`):
```bash
docker compose up -d # bitcoind, alice, bob, carol, cln, rtl
./scripts/seed.sh # fund, connect, open channels, make payments
```
Then open <http://localhost:3000>, password `rtldev`; all four nodes show in the switcher.
Reset to a clean slate: `docker compose down -v && docker compose up -d && ./scripts/seed.sh`.
Helpers and logs:
```bash
bin/b-cli getblockcount # bitcoin-cli
bin/ln-cli alice getinfo # lncli (node name required; handles --lnddir)
bin/ln-cli bob fwdinghistory
docker compose logs -f rtl
```
Testing the **BTCPay Server integration** (RTL in single-sign-on mode behind a proxy) —
behind a compose profile, so a plain `up` does not start it:
```bash
docker compose --profile sso up -d
./scripts/verify-sso.sh # 11 assertions over the whole entry path; non-zero on failure
open "$(bin/sso-url)" # the link BTCPay renders on its Services page
```
Key facts when working with the fixture:
- **Run `scripts/verify-sso.sh` after touching authentication, CSRF or static serving.**
BTCPay reaches RTL over a path the standalone login never exercises — a rotating cookie
file, an unregistered `/rtl/api/authenticate/cookie` URL that falls through to the
catch-all in `server/utils/app.ts`, and a reverse proxy. Note `GET /rtl/` is served by
`express.static` and mints **no** `XSRF-TOKEN`; only the catch-all does, so a client
entering there 403s on its first POST. That is long-standing, not a regression.
- **`scripts/seed.sh` is deterministic but not idempotent.** Every amount is fixed, so a
fresh run always produces identical state (screenshots differ only by your change) — so
**do not introduce randomness**. It refuses to run twice against an already-seeded
network; use the `down -v` reset above to start over.
- Seed creates: 10M sat on-chain per node; channels alice→bob (5M), bob→carol (3M),
cln→alice (4M) and eclair→bob (3.5M); 5 routed alice→carol payments + 2 direct
alice→bob + 2 direct eclair→bob; 2 unpaid invoices on carol + 1 on eclair;
carol as MERCHANT, everyone else OPERATOR.
- **`rtl/RTL-Config.regtest.json`** is the tracked config template. RTL rewrites its config
on startup, so an init container copies it into a volume rather than bind-mounting it
(a read-only mount → `EROFS`; a writable one would edit a tracked file). It's not named
`RTL-Config.json` because `.gitignore` matches that bare name at any depth.
- **Payments right after a channel opens fail** until the graph propagates to the sender;
the seed waits for this and so should anything you script.
- **Eclair node** (`eclair`, `polarlightning/eclair` — the official `acinq/eclair` image is
amd64-only and stale): RTL talks to its HTTP API with basic auth (`lnApiPassword`). Eclair
has no wallet of its own — `eclair-wallet-init` creates a dedicated `eclair` bitcoind
wallet before it starts, else it grabs the mining wallet. Its channels confirm at 8 blocks
(`channel.min-depth-blocks`), not 6. Helper: `bin/e-cli <eclair-cli args>`.
- **Not included:** the Boltz swap service.
## Testing an unreleased branch against the fixture
The `rtl` service defaults to a published image but is overridable — build your branch and
point the fixture at it:
```bash
docker build -t rtl:pr . # from repo root (RTL/)
cd docker && RTL_IMAGE=rtl:pr docker compose up -d
```
To confirm your change is actually running, grep inside the container: compiled backend at
`/RTL/backend/...`, built frontend bundle at `/RTL/frontend/*.js`.
- **Reproduce a disconnected CLN channel** (to exercise `peer_connected` states): the `cln`
node has a channel to alice, so `docker compose stop alice` flips it to disconnected within
~1s. Gotcha: **`docker compose up -d rtl` restarts stopped dependencies** (rtl `depends_on`
them), silently reconnecting the peer — don't re-run `up` on rtl mid-test. Restore with
`docker compose start alice`.
- **Driving RTL's API for verification** (host→container network is often blocked; run a Node
script via `docker compose exec -T rtl node < script.js`): the base href is `/rtl`, so all
API paths are `/rtl/api/...`; auth needs the CSRF handshake (`GET /` for the `XSRF-TOKEN`,
echoed as an `x-xsrf-token` header) and a **SHA256-hashed** password; and you must call
`/rtl/api/cln/getinfo` before CLN channel endpoints (it initializes the session's rune auth,
else `listPeerChannels` 401s). Prefer verifying the data layer (API) separately from frontend
rendering — a template can crash mid-render while the API returns correct data.

122
CLAUDE.md Normal file
View file

@ -0,0 +1,122 @@
# RTL — notes for AI coding agents
RTL (Ride The Lightning) is a device-agnostic web UI for Lightning node operations:
an Angular single-page frontend plus a Node/Express backend, both TypeScript.
`CONTRIBUTING.md` is the process document — how to install, run the dev servers, package a
build, open a PR, add a library, and handle Dependabot. **Read it first.** This file covers
only the things that are easy to get wrong and aren't obvious from the tree.
## Source vs. generated — read this before editing anything
| Directory | What it is | Edit it? |
|-------------|-----------------------------------|----------|
| `src/` | Angular frontend source | yes |
| `server/` | Express backend source | yes |
| `frontend/` | Built AOT bundle, **committed** | never by hand |
| `backend/` | Compiled `server/` output, **committed** | never by hand |
`frontend/` and `backend/` look like ignorable build artifacts, but they are tracked in git
and are expected to stay in sync with the sources. So a code change is a two-step edit:
change `src/`/`server/`, then rebuild and commit the regenerated output in the same PR.
```bash
npm run buildbackend # tsc: server/ -> backend/
npm run buildfrontend # ng build --configuration production: src/ -> frontend/
```
`backend/` is a plain `tsc` transpile of `server/`, so it changes only when `server/` does —
a dependency bump alone won't move it. `frontend/` is a bundle, so it also carries the app
version and any bundled dependency.
## The three-implementation pattern
RTL supports three Lightning implementations, and the layout mirrors that everywhere:
```
src/app/{lnd,cln,eclair,shared}/
server/controllers/{lnd,cln,eclair,shared}/
server/routes/{lnd,cln,eclair,shared}/
```
A feature or fix usually touches the matching folder in **each layer** for the
implementations it affects; genuinely cross-cutting logic belongs in `shared/`. When fixing a
bug in one implementation, check whether the same shape exists in the other two — they
frequently do, since the controllers were written in parallel.
## Commands, and where they bite
- **Install with `npm ci --legacy-peer-deps`**, not `npm install`. Plain `npm ci` fails on an
`ERESOLVE` conflict from `@fortawesome/angular-fontawesome`.
- **`npm run server` only works on Windows** — it sets `NODE_ENV` with `set X=Y&&` syntax. On
macOS/Linux use `npm run serverUbuntu`.
- **`npm run lint` and `npm run test` must both be green before a PR.** Nothing will check
this for you: no build or test CI runs on an open PR. `checks.yml` fires on
`pull_request: closed` (i.e. on merge) and on tags/releases, and `rtlreviewbot` only on a
requested review or a comment — so running both locally is the only gate before merge.
- If lint reports hundreds of template "Parsing error" failures, look for a stale
**`coverage/`** directory (git-ignored Karma output). The template linter walks its HTML
report. Delete it and re-run.
- The repo README is at **`.github/README.md`** — there is none at the root.
## Branches and releases
- **PRs target the current `Release-x.y.z` branch, not `master`.** Because release branches
merge into `master` by *rebase*, a `Fixes #N` reference never auto-closes its issue (GitHub
only does that for the default branch) — close it manually after the merge.
- **Every PR adds its own release-note entry**, in the same PR as the change:
`release-notes/Release-notes-<x.y.z>.md`, under `## Bug Fixes`, `## Enhancements`,
`## Code Health` or `## Developer Tooling`. Create the file if it doesn't exist yet.
Link the PR and any issue, and state the root cause briefly. Because the PR number isn't
known until the PR exists, commit the entry with `#TBD` and follow up with a
`Fill in PR number in release note (#N)` commit.
### If a release ships while your PR is open
The rebase-merge rewrites every commit of the release branch to a new hash, and the next
release branch is cut from `master` — so a branch based on the old release branch shares no
recent ancestor with the new one. Retargeting it makes the merge base collapse to before the
release cycle, and GitHub replays the entire cycle into your PR: hundreds of files, and a
`CONFLICTING` state. Replay just your own commits instead:
```bash
git rebase --onto Release-<new> Release-<old> <your-branch>
```
Then confirm `git diff Release-<old>..<old-head>` is identical to
`git diff Release-<new>..<new-head>` before force-pushing. Retargeting *before* the release
branch is merged avoids the problem entirely.
## Testing against real nodes
`docker/` is a self-contained regtest fixture — bitcoind, three LND nodes, Core Lightning and
Eclair, wired to RTL — for end-to-end testing across all three implementations. See
`docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only;
every credential in it is throwaway.
The fixture also carries a **BTCPay Server SSO harness** behind a compose profile
(`docker compose --profile sso up -d`). BTCPay bundles RTL and reaches it over a path the
standalone login never exercises: a rotating cookie file, an unregistered
`/rtl/api/authenticate/cookie` URL that is not a route at all and falls through to the
catch-all in `server/utils/app.ts`, and a reverse proxy serving it under `/rtl`. Run
`docker/scripts/verify-sso.sh` (11 assertions, exits non-zero) after touching
authentication, CSRF or static serving — none of that path is covered by logging into the
fixture's own RTL. One trap it encodes: `GET /rtl/` is served by `express.static`, which
sits above the catch-all and mints no `XSRF-TOKEN`, so a client entering there gets a 403
on its first POST. That is long-standing behaviour, not a regression.
Backend regression tests live in `test/backend/` (plain `node:test`, run against the
compiled `backend/`). `npm run test` compiles the backend, then runs them
(`npm run testbackend`) before the frontend Karma/Jasmine specs, so they never test stale
code. For backend changes, also verify against the fixture and say so in the PR.
## Conventions
- **Be conservative about dependencies.** This is security-sensitive software. Prefer what's
already there, and raise an issue before adding anything. Never run `npm audit fix` — it
reaches for breaking major bumps. Dependabot PRs are batched, not merged individually; see
`CONTRIBUTING.md`.
- Match the surrounding code's style, naming and structure. Only fix style in code you're
already changing.
- `RTL-Config.json` is local runtime config and git-ignored; `Sample-RTL-Config.json` is the
template, and `RTL.conf` is an alternate config format.

View file

@ -53,7 +53,10 @@ export const getChannels = (req, res, next) => {
options.form = req.query;
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form });
}
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options });
// Log the call's shape only. Eclair authenticates with HTTP basic auth, so the options
// object carries the node's lnApiPassword in its authorization header, and node logs are
// routinely shared when debugging.
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: { url: options.url, form: options.form } });
if (common.read_dummy_data) {
common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); });
}

View file

@ -4,10 +4,10 @@ import { Common } from '../../utils/common.js';
let options = null;
const logger = Logger;
const common = Common;
export const getAliasForChannel = (selNode, channel) => {
export const getAliasForChannel = (selNode, channel, requestOptions) => {
const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : '';
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((aliasBody) => {
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(requestOptions).then((aliasBody) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias });
channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20);
return channel;
@ -30,18 +30,26 @@ export const getAllChannels = (req, res, next) => {
request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body });
if (body.channels) {
return Promise.all(body.channels?.map((channel) => {
body.channels.forEach((channel) => {
local = (channel.local_balance) ? +channel.local_balance : 0;
remote = (channel.remote_balance) ? +channel.remote_balance : 0;
total = local + remote;
channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3);
return getAliasForChannel(req.session.selectedNode, channel);
})).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
return res.status(200).json(body);
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get All Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getChannelAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
common.runWithConcurrencyLimit(getChannelAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get All Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get All Channel Aliases Error', error: e.message });
}
}
});
}
else {
@ -66,26 +74,32 @@ export const getPendingChannels = (req, res, next) => {
if (!body.total_limbo_balance) {
body.total_limbo_balance = 0;
}
const promises = [];
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getPendingAliasesTasks = [];
if (body.pending_open_channels && body.pending_open_channels.length > 0) {
body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) {
body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.pending_closing_channels && body.pending_closing_channels.length > 0) {
body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.waiting_close_channels && body.waiting_close_channels.length > 0) {
body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
return Promise.all(promises).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
return res.status(200).json(body);
}).
catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get Pending Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
common.runWithConcurrencyLimit(getPendingAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Pending Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Pending Channel Aliases Error', error: e.message });
}
}
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode);
@ -102,15 +116,23 @@ export const getClosedChannels = (req, res, next) => {
options.qs = req.query;
request(options).then((body) => {
if (body.channels && body.channels.length > 0) {
return Promise.all(body.channels?.map((channel) => {
body.channels.forEach((channel) => {
channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type;
return getAliasForChannel(req.session.selectedNode, channel);
})).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
return res.status(200).json(body);
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get Closed Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getClosedAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
common.runWithConcurrencyLimit(getClosedAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Closed Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Closed Channel Aliases Error', error: e.message });
}
}
});
}
else {

View file

@ -4,9 +4,9 @@ import { Common } from '../../utils/common.js';
let options = null;
const logger = Logger;
const common = Common;
export const getAliasFromPubkey = (selNode, pubkey) => {
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((res) => {
export const getAliasFromPubkey = (selNode, pubkey, requestOptions) => {
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(requestOptions).then((res) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias });
return res.node.alias;
}).
@ -83,19 +83,25 @@ export const getQueryRoutes = (req, res, next) => {
request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Graph', msg: 'Query Routes Received', data: body });
if (body.routes && body.routes.length && body.routes.length > 0 && body.routes[0].hops && body.routes[0].hops.length && body.routes[0].hops.length > 0) {
return Promise.all(body.routes[0].hops?.map((hop) => getAliasFromPubkey(req.session.selectedNode, hop.pub_key))).
then((values) => {
body.routes[0].hops?.map((hop, i) => {
hop.hop_sequence = i + 1;
hop.pubkey_alias = values[i];
return hop;
});
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body });
res.status(200).json(body);
}).
catch((errRes) => {
const err = common.handleError(errRes, 'Graph', 'Get Query Routes Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getRouteAliasesTasks = body.routes[0].hops.map((hop) => () => getAliasFromPubkey(selNode, hop.pub_key, { ...requestOptions }));
common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, (values) => {
try {
body.routes[0].hops?.map((hop, i) => {
hop.hop_sequence = i + 1;
hop.pubkey_alias = typeof values[i] === 'string' ? values[i] : 'Unknown';
return hop;
});
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body });
res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Query Routes Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Query Routes Error', error: e.message });
}
}
});
}
else {
@ -145,14 +151,21 @@ export const getAliasesForPubkeys = (req, res, next) => {
}
if (req.query.pubkeys) {
const pubkeyArr = req.query.pubkeys.split(',');
return Promise.all(pubkeyArr?.map((pubkey) => getAliasFromPubkey(req.session.selectedNode, pubkey))).
then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: values });
res.status(200).json(values);
}).
catch((errRes) => {
const err = common.handleError(errRes, 'Graph', 'Get Aliases for Pubkeys Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getAliasesTasks = pubkeyArr.map((pubkey) => () => getAliasFromPubkey(selNode, pubkey, { ...requestOptions }));
common.runWithConcurrencyLimit(getAliasesTasks, 20, (values) => {
try {
const safeValues = values.map((v) => (typeof v === 'string' ? v : 'Unknown'));
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: safeValues });
res.status(200).json(safeValues);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Aliases for Pubkeys Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Aliases for Pubkeys Error', error: e.message });
}
}
});
}
else {

View file

@ -1,6 +1,6 @@
import jwt from 'jsonwebtoken';
import * as fs from 'fs';
import { sep } from 'path';
import { resolve, sep } from 'path';
import ini from 'ini';
import parseHocon from 'hocon-parser';
import request from '../../utils/request.js';
@ -76,7 +76,23 @@ export const getCurrencyRates = (req, res, next) => {
};
export const getFile = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' });
const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak');
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) => {
@ -89,7 +105,8 @@ export const getFile = (req, res, next) => {
return res.status(err.statusCode).json({ message: err.error, error: err.error });
}
else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data });
// 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);
}
});
@ -109,7 +126,6 @@ export const getApplicationSettings = (req, res, next) => {
delete appConfData.SSO.rtlCookiePath;
delete appConfData.SSO.cookieValue;
delete appConfData.SSO.logoutRedirectLink;
appConfData.secret2FA = '';
appConfData.dbDirectoryPath = '';
appConfData.nodes[selNodeIdx].authentication = new Authentication();
delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath;
@ -201,7 +217,12 @@ 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) {
// 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;
@ -220,7 +241,9 @@ 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) {
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;
@ -281,7 +304,7 @@ export const updateApplicationSettings = (req, res, next) => {
const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode)));
runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes];
}
common.appConfig = JSON.parse(JSON.stringify({
const newAppConfig = JSON.parse(JSON.stringify({
...runtimeConfig,
selectedNodeIndex: config.selectedNodeIndex !== undefined ?
config.selectedNodeIndex : common.appConfig.selectedNodeIndex,
@ -292,21 +315,42 @@ export const updateApplicationSettings = (req, res, next) => {
rtlConfFilePath: common.appConfig.rtlConfFilePath,
rtlPass: common.appConfig.rtlPass
}));
const fileConfig = JSON.parse(JSON.stringify(common.appConfig));
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;
});
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));
// 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';

View file

@ -19,6 +19,9 @@ const loginInterval = setInterval(() => {
}
}
}, LOCKING_PERIOD);
// The sweeper must not hold the event loop open on its own (it would keep
// `node --test` or a CLI invocation alive for the full 30-minute period).
loginInterval.unref();
export const getFailedInfo = (reqIP, currentTime) => {
let failed = { count: 0, lastTried: currentTime };
if ((!failedLoginAttempts[reqIP]) || (currentTime > (failed.lastTried + LOCKING_PERIOD))) {
@ -45,6 +48,21 @@ const handleMultipleFailedAttemptsError = (failed, currentTime, errMsg) => {
}
};
export const verifyToken = (twoFAToken) => !!(common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && otplib.authenticator.check(twoFAToken, common.appConfig.secret2FA));
// Mirrors isAuthenticated: a request carrying a valid session JWT has already
// completed 2FA at login, since tokens are only minted after verification when
// 2FA is enabled. Used to exempt in-app re-authorization (e.g. the password
// prompt before on-chain sends) from the TOTP requirement without opening a
// password-only path.
const hasValidAuthToken = (req) => {
try {
const token = req.headers.authorization.split(' ')[1];
jwt.verify(token, common.secret_key);
return true;
}
catch (error) {
return false;
}
};
export const authenticateUser = (req, res, next) => {
const { authenticateWith, authenticationValue, twoFAToken } = req.body;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'Authenticating User..' });
@ -84,8 +102,15 @@ export const authenticateUser = (req, res, next) => {
const failed = getFailedInfo(reqIP, currentTime);
const password = authenticationValue;
if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) {
if (twoFAToken && twoFAToken !== '') {
if (!verifyToken(twoFAToken)) {
// Gate on the server-side 2FA configuration, not on the request: when 2FA is
// enabled a token is mandatory, so a request omitting twoFAToken is rejected
// instead of silently skipping verification. The login UI keys its token prompt
// on enable2FA, so both fields are consulted — a stale secret with 2FA disabled
// must not lock the operator out of a UI that never prompts for a token.
// Requests with a valid session token (in-app re-authorization, e.g. the
// password prompt before on-chain sends) are exempt from the TOTP requirement.
if (common.appConfig.enable2FA && common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && !hasValidAuthToken(req)) {
if (typeof twoFAToken !== 'string' || twoFAToken === '' || !verifyToken(twoFAToken)) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } });
failed.count = failed.count + 1;
failed.lastTried = currentTime;

View file

@ -1,9 +1,12 @@
import exprs from 'express';
const { Router } = exprs;
import { authenticateUser, verifyToken, resetPassword, logoutUser } from '../../controllers/shared/authenticate.js';
import { isAuthenticated } from '../../utils/authCheck.js';
const router = Router();
router.post('/', authenticateUser);
router.post('/token', verifyToken);
router.post('/reset', resetPassword);
// Password changes mint a fresh session token, so the route requires an existing
// authenticated session; the frontend interceptor attaches it for the settings UI.
router.post('/reset', isAuthenticated, resetPassword);
router.get('/logout', logoutUser);
export default router;

View file

@ -22,22 +22,37 @@ export class CommonService {
{ name: 'JUL', days: 31 }, { name: 'AUG', days: 31 }, { name: 'SEP', days: 30 }, { name: 'OCT', days: 31 }, { name: 'NOV', days: 30 }, { name: 'DEC', days: 31 }
];
this.maskPasswords = (obj) => {
const keys = Object.keys(obj);
const length = keys.length;
if (length !== 0) {
for (let i = 0; i < length; i++) {
if (typeof obj[keys[i]] === 'object') {
keys[keys[i]] = this.maskPasswords(obj[keys[i]]);
}
if (typeof keys[i] === 'string' &&
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
keys[i].toLowerCase().includes('rpcuser'))) {
obj[keys[i]] = '*'.repeat(20);
// Clone up front: masking a live config object must not blank the credentials LN
// requests authenticate with (mirrors removeSecureData).
const masked = JSON.parse(JSON.stringify(obj));
const maskRecursive = (current) => {
const keys = Object.keys(current);
const length = keys.length;
if (length !== 0) {
for (let i = 0; i < length; i++) {
// Header maps always carry credentials in this codebase (macaroon, rune, basic
// auth). Key-substring matching cannot catch them without also hiding the *Path
// fields the settings UI legitimately shows, so mask the whole map.
if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') {
Object.keys(current[keys[i]]).forEach((headerKey) => { current[keys[i]][headerKey] = '*'.repeat(20); });
}
else if (current[keys[i]] && typeof current[keys[i]] === 'object') {
// Truthiness guard: null is 'object' too and must not reach Object.keys.
maskRecursive(current[keys[i]]);
}
if (typeof keys[i] === 'string' &&
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('rpcauth') ||
keys[i].toLowerCase().includes('secret2fa') || keys[i].toLowerCase().includes('cookievalue') ||
keys[i].toLowerCase().includes('rtlpass') || keys[i].toLowerCase().includes('runevalue'))) {
current[keys[i]] = '*'.repeat(20);
}
}
}
}
return obj;
return current;
};
return maskRecursive(masked);
};
this.removeAuthSecureData = (node) => {
if (node.authentication) {
@ -50,25 +65,55 @@ export class CommonService {
return node;
};
this.removeSecureData = (config) => {
delete config.rtlConfFilePath;
delete config.rtlPass;
delete config.multiPass;
delete config.multiPassHashed;
delete config.secret2FA;
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
return config;
// Clone before deleting: cookieValue is runtime-only, so mutating a caller's live
// appConfig would destroy SSO state with no way to restore it.
const sanitized = JSON.parse(JSON.stringify(config));
delete sanitized.rtlConfFilePath;
delete sanitized.rtlPass;
delete sanitized.multiPass;
delete sanitized.multiPassHashed;
delete sanitized.secret2FA;
// The SSO cookie is a live bearer credential; it must never leave the server.
if (sanitized.SSO) {
delete sanitized.SSO.cookieValue;
}
sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node));
return sanitized;
};
this.addSecureData = (config) => {
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
config.rtlPass = this.appConfig.rtlPass;
config.multiPassHashed = this.appConfig.multiPassHashed;
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath;
// Pin the hash only when the server holds one: on a default install's first boot the
// file already has multiPassHashed but the in-memory config does not, and pinning
// undefined would erase the only password from the file on save, bricking the boot.
if (this.appConfig.multiPassHashed) {
config.multiPassHashed = this.appConfig.multiPassHashed;
}
else {
delete config.multiPassHashed;
}
// Deployment-level switches are pinned to server-held values: the settings API must
// not flip the authentication mode (disableAuth, SSO) or move SSO fields, the
// password policy, or the database location; no UI flow writes them. Pinning the
// whole SSO object also means a trimmed or missing SSO object can never wipe server
// state.
config.disableAuth = this.appConfig.disableAuth;
config.allowPasswordUpdate = this.appConfig.allowPasswordUpdate;
config.dbDirectoryPath = this.appConfig.dbDirectoryPath;
config.SSO = JSON.parse(JSON.stringify(this.appConfig.SSO || {}));
if (this.appConfig.multiPass) {
config.multiPass = this.appConfig.multiPass;
}
if (config.secret2FA === this.appConfig.secret2FA) {
// Restore the TOTP seed when the client omits it — and when it sends an empty seed
// while still claiming 2FA is on (an inconsistent pair no honest flow produces).
// The settings UI's enable flow sends a non-empty seed; its disable flow sends an
// empty seed with enable2FA false. Both are honored.
if (config.secret2FA === undefined || (config.secret2FA === '' && config.enable2FA)) {
config.secret2FA = this.appConfig.secret2FA;
}
// enable2FA derives from the seed, matching the boot-time derivation in config.ts,
// so the two fields can never diverge after a save.
config.enable2FA = !!config.secret2FA;
const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []);
config.nodes?.forEach((node) => {
const appConfigNode = appConfigNodes.get(node.index);
@ -103,7 +148,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err });
}
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options Set' });
return swapOptions;
};
this.getBoltzServerOptions = (req) => {
@ -121,7 +166,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err });
}
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options Set' });
return boltzOptions;
};
this.getOptions = (req) => {
@ -167,7 +212,7 @@ export class CommonService {
}
}
if (req.session.selectedNode) {
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode, data: req.session.selectedNode.authentication.options });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode });
}
return { status: 200, message: 'Updated Successfully' };
}
@ -237,7 +282,7 @@ export class CommonService {
form: ''
};
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode });
});
this.updateSelectedNodeOptions(req);
}
@ -345,10 +390,11 @@ export class CommonService {
this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) });
let newErrorObj = { statusCode: 500, message: '', error: '' };
if (err.code && err.code === 'ENOENT') {
// The absolute path stays in the server log above but is not echoed to clients.
newErrorObj = {
statusCode: 500,
message: 'No such file or directory ' + (err.path ? err.path : ''),
error: 'No such file or directory ' + (err.path ? err.path : '')
message: 'No such file or directory',
error: 'No such file or directory'
};
}
else {

View file

@ -302,7 +302,9 @@ export class ConfigService {
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err });
}
this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log';
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) });
// maskPasswords keeps paths visible for debugging while redacting credential
// fields such as lnApiPassword before they reach the log file.
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(this.common.nodes[idx])) });
const log_file = this.common.nodes[idx].settings.logFile;
if (fs.existsSync(log_file || '')) {
fs.writeFile((log_file || ''), '', () => { });

View file

@ -74,6 +74,9 @@ docker compose up -d # bitcoind, alice, bob, carol, cln, eclair, rtl
./scripts/seed.sh # fund, connect, open channels, make payments
```
To also bring up the BTCPay single-sign-on harness, add `--profile sso` — see
[BTCPay SSO harness](#btcpay-sso-harness).
Then open <http://localhost:3000> — password `rtldev`. All five nodes (alice, bob,
carol, cln, eclair) appear in the node switcher.
@ -118,6 +121,7 @@ bin/ln-cli bob listchannels
bin/ln-cli bob fwdinghistory # forwarding history
bin/e-cli getinfo # eclair-cli
bin/e-cli channels
bin/sso-url # BTCPay-style SSO link (needs --profile sso)
docker compose exec cln lightning-cli --network=regtest listpeerchannels # Core Lightning
```
@ -128,6 +132,94 @@ docker compose logs -f rtl
docker compose logs alice
```
## BTCPay SSO harness
BTCPay Server bundles RTL and runs it in single-sign-on mode, reached through a very
different entry path than the standalone login: no password, a rotating cookie, and a
reverse proxy in front. That path has broken before without the standalone flow
noticing, so the fixture can reproduce it.
It is behind a compose profile, so a plain `docker compose up -d` does not start it:
```bash
docker compose --profile sso up -d
./scripts/verify-sso.sh # 11 assertions over the whole entry path
open "$(bin/sso-url)" # or click through it yourself
```
`bin/sso-url` prints the link BTCPay renders on its Services page. Following it lands
you in RTL already authenticated, against the `alice` node.
### How the flow works
```mermaid
sequenceDiagram
participant B as Browser
participant P as rtl-sso-proxy<br/>(stands in for traefik)
participant R as rtl-sso<br/>(RTL_SSO=1)
participant C as .cookie<br/>(shared volume)
R->>C: writes 64 random bytes at startup
Note over B: bin/sso-url reads the cookie —<br/>BTCPay reads the same file
B->>P: GET /rtl/api/authenticate/cookie?access-key=<cookie>
P->>R: same URI, prefix passed through
R-->>B: not a registered route → catch-all:<br/>mints XSRF-TOKEN, serves index.html
B->>P: POST /rtl/api/authenticate<br/>{ PASSWORD, sha256(access-key) }
P->>R:
R->>C: matches → rotates the cookie
R-->>B: JWT
```
The three services are `rtl-sso-config-init` (stages `rtl/RTL-Config.sso.json`, same
copy-into-a-volume dance as the standalone RTL), `rtl-sso` (RTL with `RTL_SSO=1`,
`RTL_COOKIE_PATH` and `LOGOUT_REDIRECT_LINK` — the env block is lifted verbatim from
BTCPay's own compose fragment), and `rtl-sso-proxy` (nginx standing in for BTCPay's
traefik). `RTL_IMAGE` overrides both RTL containers at once, so a branch build gets
tested through both entry paths.
It is a second RTL container rather than a flag on the first because RTL picks one
authentication mode at startup — SSO and the password login cannot coexist in one
instance. Both are up at the same time on different ports.
### Things this makes visible
**No prefix stripping anywhere.** RTL is built with `<base href="/rtl/">` and mounts
every route under `baseHref '/rtl'`, so BTCPay's traefik — and the nginx here — pass
`/rtl/…` through unmodified. The proxy deliberately 404s everything outside `/rtl`, so
a request escaping the prefix shows up as a failure instead of being quietly served.
**The entry URL is not a real route.** `/rtl/api/authenticate/cookie` matches nothing in
`server/routes/shared/authenticate.ts`; it falls through to the catch-all in
`server/utils/app.ts`, which is what mints the `XSRF-TOKEN` cookie and serves the SPA.
The access-key is the raw cookie file content — the frontend sha256s it before posting
and the backend compares against `sha256(cookieValue)`.
**`GET /rtl/` mints no CSRF token.** That path is served by `express.static`, which
sits *above* the catch-all, so a client entering there has no `XSRF-TOKEN` and its first
POST gets a 403. Only the catch-all mints one. This is long-standing behaviour, not a
regression — but it is why `verify-sso.sh` always seeds its cookie jar from the entry
URL, and worth remembering before concluding that CSRF is broken.
**The cookie is effectively single-use.** Authenticating rotates it, so a stale
`bin/sso-url` link fails. BTCPay re-reads the file on every page render, which is why
this is invisible in normal use.
### What it does not cover
BTCPay itself is not here — no postgres, nbxplorer or btcpayserver container. So this
does not exercise BTCPay *generating* the link, its Services page, or its own upgrades.
For that, run BTCPay's own regtest stack and point it at a local image:
```bash
# in a btcpayserver-docker checkout, after building an RTL image locally
docker build -t shahanafarooqui/rtl:dev /path/to/RTL
# then edit the rtl image tag in the generated docker-compose, or set it in
# docker-compose-generator/docker-fragments/bitcoin-lnd.yml before generating
```
That tests the real composition rather than this reconstruction of it; the harness here
is the fast everyday check.
## Notes and gotchas
**RTL's config.** `rtl/RTL-Config.regtest.json` is the tracked template. RTL rewrites
@ -180,3 +272,7 @@ one LND uses, eclair never sees new blocks and channels never confirm.
## Not included
The Boltz swap service.
BTCPay Server itself (postgres + nbxplorer + btcpayserver). The `sso` profile
reproduces the entry path BTCPay uses to reach RTL without running BTCPay — see
[BTCPay SSO harness](#btcpay-sso-harness) for what that covers and what it does not.

39
docker/bin/sso-url Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# Print the BTCPay-style single-sign-on entry URL for the SSO harness.
#
# docker compose --profile sso up -d
# bin/sso-url # print it
# open "$(bin/sso-url)" # or follow it straight into RTL
#
# This is the link BTCPay renders on its Services page. BTCPay builds it from
# BTCPAY_BTCEXTERNALRTL="server=/rtl/api/authenticate/cookie;cookiefile=..."
# by reading the cookie file RTL wrote and appending it as ?access-key=. The
# value is the raw file content: RTL's frontend sha256s it before posting
# (src/app/app.component.ts) and the backend compares against
# sha256(cookieValue), so no hashing happens here.
#
# Authenticating rotates the cookie (common.refreshCookie), so re-run this for
# each login -- exactly as BTCPay re-reads the file on every page render.
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck disable=SC1091
[ -f .env ] && source .env
port="${RTL_SSO_PORT:-3001}"
if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then
echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2
exit 1
fi
cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')"
if [ -z "$cookie" ]; then
echo "The cookie file /RTL/cookie/.cookie is empty. Is RTL_SSO=1 set on rtl-sso?" >&2
exit 1
fi
echo "http://localhost:${port}/rtl/api/authenticate/cookie?access-key=${cookie}"

View file

@ -17,6 +17,9 @@ volumes:
eclair_data:
rtl_db:
rtl_config:
rtl_sso_db:
rtl_sso_config:
rtl_sso_cookie:
x-lnd: &lnd
image: polarlightning/lnd:0.20.0-beta
@ -285,7 +288,7 @@ services:
container_name: ${COMPOSE_PROJECT_NAME}_rtl
# Defaults to the published image; override with RTL_IMAGE (e.g. a locally built
# branch image) to test unreleased changes: RTL_IMAGE=rtl:pr1625 docker compose up.
image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.8}
image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10}
restart: unless-stopped
depends_on:
rtl-config-init:
@ -311,3 +314,87 @@ services:
- carol_data:/lnd/carol:ro
- cln_data:/cln:ro
- rtl_db:/RTL/database
# ---------------------------------------------------------------------------
# BTCPay Server SSO harness -- profile "sso", so a plain 'up' does not start it:
#
# docker compose --profile sso up -d
# bin/sso-url
#
# BTCPay bundles RTL as a service and runs it in single-sign-on mode. RTL
# writes a random cookie to RTL_COOKIE_PATH; BTCPay reads that file and renders
# a link to /rtl/api/authenticate/cookie?access-key=<cookie> on its Services
# page. That path is not a registered route -- it falls through to RTL's
# catch-all, which mints the XSRF-TOKEN cookie and serves index.html, and the
# SPA then reads access-key from the query string and posts it as a password
# login. Reproducing that entry path is the whole point of this harness: it is
# where RTL's auth, CSRF and static-serving behaviour meet an external caller,
# and it has broken before without the standalone flow noticing.
#
# BTCPay itself (postgres + nbxplorer + btcpayserver) is deliberately not here.
# See README.md, "BTCPay SSO harness", for what that leaves untested and how to
# run against a real BTCPay when it matters.
# ---------------------------------------------------------------------------
# Same copy-into-a-volume dance as rtl-config-init above, and for the same
# reason: RTL rewrites its config on startup.
rtl-sso-config-init:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_config_init
profiles: ["sso"]
image: busybox:1.36
command: >
sh -c "cp /template/RTL-Config.sso.json /config/RTL-Config.json &&
chmod 644 /config/RTL-Config.json &&
echo 'sso config staged'"
volumes:
- ./rtl/RTL-Config.sso.json:/template/RTL-Config.sso.json:ro
- rtl_sso_config:/config
# A second RTL rather than a flag on the first: RTL picks one authentication
# mode at startup, so SSO and the password login cannot coexist in one
# instance. Only alice is wired up, matching BTCPay's one-node-per-RTL layout.
#
# The environment block is copied from BTCPay's own compose fragment
# (docker-compose-generator/docker-fragments/bitcoin-lnd.yml in
# btcpayserver-docker), so this exercises the env-driven SSO path BTCPay
# actually uses rather than the config-file equivalent -- which is why
# RTL-Config.sso.json leaves its SSO block zeroed and carries no multiPass.
#
# Only 'expose'd, never published: all access goes through the proxy, as it
# does under BTCPay.
rtl-sso:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso
profiles: ["sso"]
image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10}
restart: unless-stopped
depends_on:
rtl-sso-config-init:
condition: service_completed_successfully
alice:
condition: service_started
environment:
RTL_CONFIG_PATH: /RTL/config
RTL_SSO: 1
RTL_COOKIE_PATH: /RTL/cookie/.cookie
LOGOUT_REDIRECT_LINK: /server/services
expose:
- "3000"
volumes:
- rtl_sso_config:/RTL/config
- rtl_sso_cookie:/RTL/cookie
- alice_data:/lnd/alice:ro
- rtl_sso_db:/RTL/database
# Stands in for BTCPay's traefik. Routes only /rtl and /rtl/*, mirroring the
# label BTCPay puts on its RTL container; see nginx/rtl-sso.conf.
rtl-sso-proxy:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_proxy
profiles: ["sso"]
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
- rtl-sso
ports:
- "${RTL_SSO_PORT:-3001}:80"
volumes:
- ./nginx/rtl-sso.conf:/etc/nginx/conf.d/default.conf:ro

46
docker/nginx/rtl-sso.conf Normal file
View file

@ -0,0 +1,46 @@
# Reverse proxy in front of RTL running in BTCPay Server's single-sign-on mode.
#
# Stands in for the traefik instance BTCPay puts in front of its bundled RTL.
# The location regex mirrors the router rule BTCPay labels that container with:
#
# Host(`${BTCPAY_HOST}`) && (Path(`/rtl`) || PathPrefix(`/rtl/`))
#
# so only /rtl and /rtl/* are proxied and everything else 404s here. That
# strictness is deliberate: RTL is built with <base href="/rtl/"> (angular.json)
# and mounts every route under baseHref '/rtl' (server/utils/common.ts), so a
# request that escapes the prefix is a bug the harness should surface rather
# than quietly serve.
#
# The prefix is passed through unmodified -- there is no strip-prefix step to
# get wrong, because RTL expects to see it. proxy_pass without a URI part
# preserves the original request URI.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
location ~ ^/rtl(/|$) {
proxy_pass http://rtl-sso:3000;
# RTL runs with Express 'trust proxy' enabled, so these are what it sees
# as the client address in its logs and rate limiting.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# RTL's websocket lives at /rtl/api/ws and stays open for the life of the
# session. Without the upgrade headers it fails the handshake and the UI
# silently stops receiving live updates; without the long read timeout
# nginx drops it after 60s.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}

View file

@ -0,0 +1,30 @@
{
"port": "3000",
"defaultNodeIndex": 1,
"dbDirectoryPath": "/RTL/database",
"SSO": {
"rtlSSO": 0,
"rtlCookiePath": "",
"logoutRedirectLink": ""
},
"nodes": [
{
"index": 1,
"lnNode": "alice",
"lnImplementation": "LND",
"authentication": {
"macaroonPath": "/lnd/alice/data/chain/bitcoin/regtest"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://alice:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
}
]
}

110
docker/scripts/verify-sso.sh Executable file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Check the BTCPay SSO harness end to end.
#
# Walks the exact path a browser takes when an operator clicks the RTL link on
# BTCPay Server's Services page, and asserts each step. Run it after any change
# to authentication, CSRF or static serving -- this is the entry path BTCPay
# uses, and none of it is covered by logging into the standalone fixture RTL.
#
# docker compose --profile sso up -d
# ./scripts/verify-sso.sh
#
# Usage: ./scripts/verify-sso.sh (from the docker/ directory)
#
# Exits non-zero if any check fails, so it can gate a PR.
# Deliberately no -e: a failing assertion must record itself and let the rest of
# the checks run, rather than aborting on the first one. That means anything
# that would normally rely on -e needs its own guard.
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
BASE="http://localhost:${RTL_SSO_PORT:-3001}"
JAR="$(mktemp)"
JAR2="$(mktemp)"
trap 'rm -f "$JAR" "$JAR2"' EXIT
pass=0
fail=0
# Both return 0 explicitly: the checks below are written as `test && ok || bad`,
# which would also run `bad` if `ok` itself ever returned non-zero.
ok() { echo " PASS: $1"; pass=$((pass + 1)); return 0; }
bad() { echo " FAIL: $1"; fail=$((fail + 1)); return 0; }
if ! docker compose --profile sso ps --status running --services 2>/dev/null | grep -qx rtl-sso; then
echo "rtl-sso is not running. Start it with: docker compose --profile sso up -d" >&2
exit 1
fi
cookie="$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')"
echo "cookie: ${cookie:0:16}... (${#cookie} chars)"
echo
echo "1. the proxy routes only /rtl, mirroring BTCPay's traefik rule"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/")
[ "$code" = "404" ] && ok "GET / -> 404" || bad "GET / -> $code (want 404)"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/authenticate")
[ "$code" = "404" ] && ok "GET /api/authenticate -> 404" || bad "GET /api/authenticate -> $code (want 404)"
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/rtl/")
[ "$code" = "200" ] && ok "GET /rtl/ -> 200" || bad "GET /rtl/ -> $code (want 200)"
echo
echo "2. the entry URL falls through to the catch-all, which mints the CSRF token"
body=$(curl -s -c "$JAR" "$BASE/rtl/api/authenticate/cookie?access-key=$cookie")
echo "$body" | grep -q '<base href="/rtl/">' \
&& ok "entry URL serves the SPA shell with base href /rtl/" \
|| bad "entry URL did not serve index.html"
xsrf=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR")
[ -n "$xsrf" ] && ok "XSRF-TOKEN cookie minted" || bad "no XSRF-TOKEN cookie"
grep -q '_csrf' "$JAR" && ok "_csrf cookie set" || bad "no _csrf cookie"
echo
echo "3. the SPA posts sha256(access-key) as a password login"
hash=$(printf '%s' "$cookie" | shasum -a 256 | cut -d' ' -f1)
resp=$(curl -s -b "$JAR" -c "$JAR" -X POST "$BASE/rtl/api/authenticate" \
-H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $xsrf" \
-d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$hash\"}")
token=$(echo "$resp" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))' 2>/dev/null)
[ -n "$token" ] && ok "authenticated, JWT issued" || bad "auth failed: $resp"
echo
echo "4. the JWT reaches the node behind it"
info=$(curl -s -b "$JAR" "$BASE/rtl/api/lnd/getinfo" -H "Authorization: Bearer $token")
node_alias=$(echo "$info" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("alias",""))' 2>/dev/null)
[ "$node_alias" = "alice" ] && ok "GET /rtl/api/lnd/getinfo -> alias '$node_alias'" || bad "getinfo returned: ${info:0:200}"
echo
echo "5. the cookie rotates on login, so each BTCPay page render hands out a fresh one"
after=$(docker compose --profile sso exec -T rtl-sso cat /RTL/cookie/.cookie | tr -d '\r\n')
[ "$after" != "$cookie" ] && ok "cookie rotated after authentication" || bad "cookie did NOT rotate"
echo
echo "6. a wrong access-key is refused"
# The token has to come from the catch-all: GET /rtl/ is served by express.static,
# which mints no XSRF-TOKEN, and the POST would then fail CSRF (403) before it
# ever reached the access-key comparison this step is checking.
curl -s -c "$JAR2" "$BASE/rtl/api/authenticate/cookie?access-key=x" > /dev/null
x2=$(awk '/XSRF-TOKEN/ {print $7}' "$JAR2")
badhash=$(printf '%s' "not-the-cookie-value-but-long-enough-to-pass-the-length-check" | shasum -a 256 | cut -d' ' -f1)
code=$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR2" -X POST "$BASE/rtl/api/authenticate" \
-H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $x2" \
-d "{\"authenticateWith\":\"PASSWORD\",\"authenticationValue\":\"$badhash\"}")
[ "$code" = "406" ] && ok "wrong access-key -> 406" || bad "wrong access-key -> $code (want 406)"
echo
echo "7. the standalone fixture RTL is unaffected"
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${RTL_PORT:-3000}/rtl/")
[ "$code" = "200" ] && ok "standalone RTL still serving on ${RTL_PORT:-3000}" || bad "standalone RTL -> $code"
echo
echo "=== $pass passed, $fail failed ==="
[ "$fail" -eq 0 ]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1712
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "rtl",
"version": "0.15.9-beta",
"version": "0.15.10-beta",
"license": "MIT",
"type": "module",
"scripts": {
@ -16,7 +16,8 @@
"server": "set NODE_ENV=development&&nodemon --watch backend --watch server ./rtl.js",
"serverUbuntu": "NODE_ENV=development nodemon --watch backend --watch server ./rtl.js",
"testdev": "ng test --watch=true --code-coverage",
"test": "ng test --watch=false --browsers=ChromeHeadless",
"testbackend": "node --test test/backend/*.test.mjs",
"test": "npm run buildbackend && npm run testbackend && ng test --watch=false --browsers=ChromeHeadless",
"lint": "eslint"
},
"private": true,
@ -26,7 +27,7 @@
"@swimlane/ngx-charts": "23.1.0",
"angular-user-idle": "4.0.0",
"atob": "2.1.2",
"axios": "1.16.0",
"axios": "1.18.1",
"buffer": "6.0.3",
"cookie-parser": "1.4.7",
"csrf-csrf": "4.0.3",
@ -54,20 +55,20 @@
"@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/schematics": "20.7.0",
"@angular-eslint/template-parser": "20.7.0",
"@angular/animations": "20.3.26",
"@angular/animations": "20.3.27",
"@angular/build": "20.3.32",
"@angular/cdk": "20.2.14",
"@angular/cli": "20.3.32",
"@angular/common": "20.3.26",
"@angular/compiler": "20.3.26",
"@angular/compiler-cli": "20.3.26",
"@angular/core": "20.3.26",
"@angular/common": "20.3.27",
"@angular/compiler": "20.3.27",
"@angular/compiler-cli": "20.3.27",
"@angular/core": "20.3.27",
"@angular/flex-layout": "15.0.0-beta.42",
"@angular/forms": "20.3.26",
"@angular/forms": "20.3.27",
"@angular/material": "20.2.14",
"@angular/platform-browser": "20.3.26",
"@angular/platform-browser-dynamic": "20.3.26",
"@angular/router": "20.3.26",
"@angular/platform-browser": "20.3.27",
"@angular/platform-browser-dynamic": "20.3.27",
"@angular/router": "20.3.27",
"@eslint/eslintrc": "3.3.3",
"@fortawesome/angular-fontawesome": "4.0.0",
"@fortawesome/fontawesome-svg-core": "7.1.0",
@ -76,10 +77,10 @@
"@ngrx/store-devtools": "21.0.1",
"@types/jasmine": "5.1.15",
"@types/node": "20.19.30",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"dotenv": "17.2.3",
"eslint": "9.39.2",
"eslint": "9.39.5",
"eslint-plugin-deprecation": "3.0.0",
"jasmine-core": "5.13.0",
"jasmine-spec-reporter": "7.0.0",
@ -89,8 +90,7 @@
"karma-jasmine": "5.1.0",
"karma-jasmine-html-reporter": "2.1.0",
"material-icons": "1.13.14",
"nodemon": "3.1.11",
"protractor": "7.0.0",
"nodemon": "3.1.14",
"roboto-fontface": "0.10.0",
"ts-node": "10.9.2",
"typescript": "5.8.3"

View file

@ -0,0 +1,102 @@
# Release Notes — 0.15.10
This document collects the changes that go into the 0.15.10 release. Each PR merged for
this release should add its entry under the appropriate section below.
## Bug Fixes
- **Auth: harden login request validation**
([#1654](https://github.com/Ride-The-Lightning/RTL/pull/1654)).
Tightens server-side validation of authentication requests and adds regression coverage
(`test/backend/authenticate.test.mjs`). Users who have two-factor authentication enabled
are encouraged to update promptly.
- **Config & logging: reduce exposure of authentication secrets**
([#1659](https://github.com/Ride-The-Lightning/RTL/pull/1659)).
Tightens redaction of authentication material in node logs and configuration API
responses, pins deployment-level authentication settings server-side, contains backup
file downloads to the node's backup directory, and hardens the settings persistence
path. Adds regression coverage (`test/backend/common.test.mjs`). Users are encouraged
to update promptly.
- **Eclair: stop logging the node's auth header at DEBUG level**
([#1664](https://github.com/Ride-The-Lightning/RTL/pull/1664)).
`getChannels` in the Eclair channels controller logged its entire request options object,
which for Eclair carries HTTP basic auth — so raising an Eclair node's `logLevel` to
`DEBUG` wrote `authorization: Basic <base64>` into the node log, a recoverable form of the
configured `lnApiPassword`. The log now carries only the request url and form, matching
every other DEBUG log in the controllers. Present since 0.12.0 and only reachable by
opting in to `DEBUG` (the default level is `ERROR`), but it contradicted the logging
guarantee stated for #1659. Regression coverage added
(`test/backend/eclair-channels.test.mjs`). Found by auditing node logs at `DEBUG` while
verifying this release against the regtest fixture.
## Code Health
- **Bound remaining unbounded LND alias-resolution fan-outs**
([#1651](https://github.com/Ride-The-Lightning/RTL/pull/1651), fixes
[#1630](https://github.com/Ride-The-Lightning/RTL/issues/1630)).
Mirrors the `runWithConcurrencyLimit(tasks, 20, done)` pattern introduced in #1629
across the remaining unbounded `Promise.all(map(...))` alias-resolution fan-outs in
the LND graph and channels controllers, preventing a large node from firing one
alias-lookup request per peer, channel, or hop all at once.
During review, a related race condition was found and fixed: the module-level
`options` variable in these controllers was reassigned per-request, but
`getAliasForChannel` and `getAliasFromPubkey` read it by closure rather than
receiving it as a parameter. Once alias-resolution tasks were deferred across
event-loop turns by the concurrency limiter, a concurrent request to a different
node could overwrite `options` mid-fan-out, causing a task to send with the wrong
node's credentials or URL. Both functions now accept an explicit `requestOptions`
parameter, and each handler captures a per-request copy before building the task
thunks. The catch blocks inside the concurrency-limit callbacks were also updated
to log raw exceptions directly instead of routing them through `handleError`
(which expects an HTTP-error-shaped value), matching the existing pattern used
by `closeChannel`.
- **Batch dependency update resolving the open Dependabot security PRs**
([#1653](https://github.com/Ride-The-Lightning/RTL/pull/1653)).
Dependabot had three open security PRs against `master` (#1648, #1649, #1650). Rather than
merging them piecemeal (they conflict with each other on `package-lock.json` and target the
wrong branch for the release flow), the fixes were applied in one pass on the release branch.
The only production exposure was `axios`, carrying ten advisories at 1.16.0 — prototype
pollution in request-option merging, `formDataToJSON` recursion DoS, `maxBodyLength` bypasses
on fetch/HTTP2 uploads, and a `NO_PROXY` bypass — now on 1.18.1 (a patch above Dependabot's
validated 1.18.0, which was superseded during the batch). The lockfile was regenerated from
scratch rather than incrementally patched, and the flagged transitive deps were moved to their
fixed in-range versions (`fast-uri` 3.1.4, plus `form-data`, `qs`, `tough-cookie`, `tar`,
`del` and `globby`). The dev toolchain took safe patch/minor bumps: `nodemon` 3.1.14,
`eslint` 9.39.5, and `@typescript-eslint/*` 8.65.0.
The unused `protractor` devDependency was also dropped. It had been dead since the Angular
scaffold that introduced it — no `e2e/` directory, no `protractor.conf.js`, and no `e2e`
target in `angular.json`, leaving a single line in `package.json` as its only reference —
while dragging in 100 packages and the deprecated `request` stack. Removing it clears both
remaining critical advisories (`request`, `form-data`) along with fourteen others
(`adm-zip`, `selenium-webdriver`, `webdriver-manager`, `xml2js`, `tmp`, `rimraf` and the
rest of the webdriver chain).
`npm audit`: **50 vulnerabilities (2 critical, 37 high, 10 moderate, 1 low) → 29
(0 critical, 23 high, 6 moderate)**, and **production dependencies are now clean at 0**
(from 1 high). Everything still flagged is dev-only build tooling that cannot be fixed by a
version bump: the Angular CLI chain (`@hono/node-server` and `@modelcontextprotocol/sdk`
need Angular 21, i.e. `@angular/core` ^21 and TypeScript ≥5.9 — a framework migration, not a
bump; #1650 is left for that work), the `@angular-eslint` line, and the karma/jasmine stack.
None of it ships in the released bundle.
- **Angular framework patch update to 20.3.27**
([#1661](https://github.com/Ride-The-Lightning/RTL/pull/1661)).
Dependabot opened one PR per package against `master` for `@angular/core` (#1658),
`@angular/compiler` (#1657) and `@angular/common` (#1655). The framework packages are
pinned to exact versions and their peer ranges require them to move as a set, so the three
were applied as a single batch on the release branch, taking all nine 20.3.26 packages
(`animations`, `common`, `compiler`, `compiler-cli`, `core`, `forms`, `platform-browser`,
`platform-browser-dynamic`, `router`) to 20.3.27. Upstream fixes only, no advisories:
the compiler now disallows `i18n` event attributes and limits its possible-event-handler
check to property names longer than two characters, `HttpClient` distinguishes repeated
transfer-cache params, and `platform-server` picks up a newer `domino`.
This stays inside Angular 20 — the build toolchain (`@angular/build`, `@angular/cli`
20.3.32) and `@angular/cdk`/`@angular/material` (20.2.14) are already at the top of their
v20 lines, so nothing in this batch pulls in the Angular 21 migration still tracked by
#1650. `frontend/` was rebuilt for the new framework code.

View file

@ -55,7 +55,10 @@ export const getChannels = (req, res, next) => {
options.form = req.query;
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form });
}
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options });
// Log the call's shape only. Eclair authenticates with HTTP basic auth, so the options
// object carries the node's lnApiPassword in its authorization header, and node logs are
// routinely shared when debugging.
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: { url: options.url, form: options.form } });
if (common.read_dummy_data) {
common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); });
} else {

View file

@ -6,10 +6,10 @@ let options = null;
const logger: LoggerService = Logger;
const common: CommonService = Common;
export const getAliasForChannel = (selNode: SelectedNode, channel) => {
export const getAliasForChannel = (selNode: SelectedNode, channel, requestOptions) => {
const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : '';
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((aliasBody) => {
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(requestOptions).then((aliasBody) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias });
channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20);
return channel;
@ -31,20 +31,23 @@ export const getAllChannels = (req, res, next) => {
request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body });
if (body.channels) {
return Promise.all(
body.channels?.map((channel) => {
local = (channel.local_balance) ? +channel.local_balance : 0;
remote = (channel.remote_balance) ? +channel.remote_balance : 0;
total = local + remote;
channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3);
return getAliasForChannel(req.session.selectedNode, channel);
})
).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
return res.status(200).json(body);
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get All Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
body.channels.forEach((channel) => {
local = (channel.local_balance) ? +channel.local_balance : 0;
remote = (channel.remote_balance) ? +channel.remote_balance : 0;
total = local + remote;
channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3);
});
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getChannelAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
common.runWithConcurrencyLimit(getChannelAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
return res.status(200).json(body);
} catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get All Channel Aliases Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get All Channel Aliases Error', error: e.message }); }
}
});
} else {
body.channels = [];
@ -67,27 +70,30 @@ export const getPendingChannels = (req, res, next) => {
if (!body.total_limbo_balance) {
body.total_limbo_balance = 0;
}
const promises = [];
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getPendingAliasesTasks = [];
if (body.pending_open_channels && body.pending_open_channels.length > 0) {
body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) {
body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.pending_closing_channels && body.pending_closing_channels.length > 0) {
body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.waiting_close_channels && body.waiting_close_channels.length > 0) {
body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
return Promise.all(promises).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
return res.status(200).json(body);
}).
catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get Pending Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
common.runWithConcurrencyLimit(getPendingAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
return res.status(200).json(body);
} catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Pending Channel Aliases Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get Pending Channel Aliases Error', error: e.message }); }
}
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
@ -102,17 +108,20 @@ export const getClosedChannels = (req, res, next) => {
options.qs = req.query;
request(options).then((body) => {
if (body.channels && body.channels.length > 0) {
return Promise.all(
body.channels?.map((channel) => {
channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type;
return getAliasForChannel(req.session.selectedNode, channel);
})
).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
return res.status(200).json(body);
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get Closed Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
body.channels.forEach((channel) => {
channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type;
});
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getClosedAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
common.runWithConcurrencyLimit(getClosedAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
return res.status(200).json(body);
} catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Closed Channel Aliases Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get Closed Channel Aliases Error', error: e.message }); }
}
});
} else {
body.channels = [];

View file

@ -6,9 +6,9 @@ let options = null;
const logger: LoggerService = Logger;
const common: CommonService = Common;
export const getAliasFromPubkey = (selNode: SelectedNode, pubkey) => {
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((res) => {
export const getAliasFromPubkey = (selNode: SelectedNode, pubkey, requestOptions) => {
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(requestOptions).then((res) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias });
return res.node.alias;
}).
@ -80,20 +80,23 @@ export const getQueryRoutes = (req, res, next) => {
request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Graph', msg: 'Query Routes Received', data: body });
if (body.routes && body.routes.length && body.routes.length > 0 && body.routes[0].hops && body.routes[0].hops.length && body.routes[0].hops.length > 0) {
return Promise.all(body.routes[0].hops?.map((hop) => getAliasFromPubkey(req.session.selectedNode, hop.pub_key))).
then((values) => {
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getRouteAliasesTasks = body.routes[0].hops.map((hop) => () => getAliasFromPubkey(selNode, hop.pub_key, { ...requestOptions }));
common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, (values) => {
try {
body.routes[0].hops?.map((hop, i) => {
hop.hop_sequence = i + 1;
hop.pubkey_alias = values[i];
hop.pubkey_alias = typeof values[i] === 'string' ? values[i] : 'Unknown';
return hop;
});
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body });
res.status(200).json(body);
}).
catch((errRes) => {
const err = common.handleError(errRes, 'Graph', 'Get Query Routes Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
} catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Query Routes Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get Query Routes Error', error: e.message }); }
}
});
} else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes Received', data: body });
return res.status(200).json(body);
@ -138,15 +141,19 @@ export const getAliasesForPubkeys = (req, res, next) => {
if (options.error) { return res.status(options.statusCode).json({ message: options.message, error: options.error }); }
if (req.query.pubkeys) {
const pubkeyArr = req.query.pubkeys.split(',');
return Promise.all(pubkeyArr?.map((pubkey) => getAliasFromPubkey(req.session.selectedNode, pubkey))).
then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: values });
res.status(200).json(values);
}).
catch((errRes) => {
const err = common.handleError(errRes, 'Graph', 'Get Aliases for Pubkeys Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getAliasesTasks = pubkeyArr.map((pubkey) => () => getAliasFromPubkey(selNode, pubkey, { ...requestOptions }));
common.runWithConcurrencyLimit(getAliasesTasks, 20, (values) => {
try {
const safeValues = values.map((v) => (typeof v === 'string' ? v : 'Unknown'));
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: safeValues });
res.status(200).json(safeValues);
} catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Aliases for Pubkeys Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get Aliases for Pubkeys Error', error: e.message }); }
}
});
} else {
return res.status(200).json([]);
}

View file

@ -1,6 +1,6 @@
import jwt from 'jsonwebtoken';
import * as fs from 'fs';
import { sep } from 'path';
import { resolve, sep } from 'path';
import ini from 'ini';
import parseHocon from 'hocon-parser';
import request from '../../utils/request.js';
@ -81,7 +81,22 @@ export const getCurrencyRates = (req, res, next) => {
export const getFile = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' });
const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak');
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) => {
@ -91,7 +106,8 @@ export const getFile = (req, res, next) => {
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 {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data });
// 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);
}
});
@ -112,7 +128,6 @@ export const getApplicationSettings = (req, res, next) => {
delete appConfData.SSO.rtlCookiePath;
delete appConfData.SSO.cookieValue;
delete appConfData.SSO.logoutRedirectLink;
appConfData.secret2FA = '';
appConfData.dbDirectoryPath = '';
appConfData.nodes[selNodeIdx].authentication = new Authentication();
delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath;
@ -205,7 +220,12 @@ 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) {
// 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;
@ -222,7 +242,9 @@ 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) {
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;
@ -281,7 +303,7 @@ export const updateApplicationSettings = (req, res, next) => {
const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode)));
runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes];
}
common.appConfig = JSON.parse(JSON.stringify({
const newAppConfig = JSON.parse(JSON.stringify({
...runtimeConfig,
selectedNodeIndex: config.selectedNodeIndex !== undefined ?
config.selectedNodeIndex : common.appConfig.selectedNodeIndex,
@ -292,21 +314,39 @@ export const updateApplicationSettings = (req, res, next) => {
rtlConfFilePath: common.appConfig.rtlConfFilePath,
rtlPass: common.appConfig.rtlPass
}));
const fileConfig = JSON.parse(JSON.stringify(common.appConfig));
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;
});
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));
// 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);

View file

@ -21,6 +21,9 @@ const loginInterval = setInterval(() => {
}
}
}, LOCKING_PERIOD);
// The sweeper must not hold the event loop open on its own (it would keep
// `node --test` or a CLI invocation alive for the full 30-minute period).
loginInterval.unref();
export const getFailedInfo = (reqIP, currentTime) => {
let failed = { count: 0, lastTried: currentTime };
@ -49,6 +52,21 @@ const handleMultipleFailedAttemptsError = (failed, currentTime, errMsg) => {
export const verifyToken = (twoFAToken) => !!(common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && (otplib as any).authenticator.check(twoFAToken, common.appConfig.secret2FA));
// Mirrors isAuthenticated: a request carrying a valid session JWT has already
// completed 2FA at login, since tokens are only minted after verification when
// 2FA is enabled. Used to exempt in-app re-authorization (e.g. the password
// prompt before on-chain sends) from the TOTP requirement without opening a
// password-only path.
const hasValidAuthToken = (req) => {
try {
const token = req.headers.authorization.split(' ')[1];
jwt.verify(token, common.secret_key);
return true;
} catch (error) {
return false;
}
};
export const authenticateUser = (req, res, next) => {
const { authenticateWith, authenticationValue, twoFAToken } = req.body;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'Authenticating User..' });
@ -80,8 +98,15 @@ export const authenticateUser = (req, res, next) => {
const failed = getFailedInfo(reqIP, currentTime);
const password = authenticationValue;
if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) {
if (twoFAToken && twoFAToken !== '') {
if (!verifyToken(twoFAToken)) {
// Gate on the server-side 2FA configuration, not on the request: when 2FA is
// enabled a token is mandatory, so a request omitting twoFAToken is rejected
// instead of silently skipping verification. The login UI keys its token prompt
// on enable2FA, so both fields are consulted — a stale secret with 2FA disabled
// must not lock the operator out of a UI that never prompts for a token.
// Requests with a valid session token (in-app re-authorization, e.g. the
// password prompt before on-chain sends) are exempt from the TOTP requirement.
if (common.appConfig.enable2FA && common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && !hasValidAuthToken(req)) {
if (typeof twoFAToken !== 'string' || twoFAToken === '' || !verifyToken(twoFAToken)) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } });
failed.count = failed.count + 1;
failed.lastTried = currentTime;

View file

@ -1,12 +1,15 @@
import exprs from 'express';
const { Router } = exprs;
import { authenticateUser, verifyToken, resetPassword, logoutUser } from '../../controllers/shared/authenticate.js';
import { isAuthenticated } from '../../utils/authCheck.js';
const router = Router();
router.post('/', authenticateUser);
router.post('/token', verifyToken);
router.post('/reset', resetPassword);
// Password changes mint a fresh session token, so the route requires an existing
// authenticated session; the frontend interceptor attaches it for the settings UI.
router.post('/reset', isAuthenticated, resetPassword);
router.get('/logout', logoutUser);
export default router;

View file

@ -27,23 +27,37 @@ export class CommonService {
constructor() {}
public maskPasswords = (obj) => {
const keys = Object.keys(obj);
const length = keys.length;
if (length !== 0) {
for (let i = 0; i < length; i++) {
if (typeof obj[keys[i]] === 'object') {
keys[keys[i]] = this.maskPasswords(obj[keys[i]]);
}
if (typeof keys[i] === 'string' &&
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
keys[i].toLowerCase().includes('rpcuser'))
) {
obj[keys[i]] = '*'.repeat(20);
// Clone up front: masking a live config object must not blank the credentials LN
// requests authenticate with (mirrors removeSecureData).
const masked = JSON.parse(JSON.stringify(obj));
const maskRecursive = (current) => {
const keys = Object.keys(current);
const length = keys.length;
if (length !== 0) {
for (let i = 0; i < length; i++) {
// Header maps always carry credentials in this codebase (macaroon, rune, basic
// auth). Key-substring matching cannot catch them without also hiding the *Path
// fields the settings UI legitimately shows, so mask the whole map.
if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') {
Object.keys(current[keys[i]]).forEach((headerKey) => { current[keys[i]][headerKey] = '*'.repeat(20); });
} else if (current[keys[i]] && typeof current[keys[i]] === 'object') {
// Truthiness guard: null is 'object' too and must not reach Object.keys.
maskRecursive(current[keys[i]]);
}
if (typeof keys[i] === 'string' &&
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('rpcauth') ||
keys[i].toLowerCase().includes('secret2fa') || keys[i].toLowerCase().includes('cookievalue') ||
keys[i].toLowerCase().includes('rtlpass') || keys[i].toLowerCase().includes('runevalue'))
) {
current[keys[i]] = '*'.repeat(20);
}
}
}
}
return obj;
return current;
};
return maskRecursive(masked);
};
public removeAuthSecureData = (node: SelectedNode) => {
@ -58,26 +72,53 @@ export class CommonService {
};
public removeSecureData = (config: ApplicationConfig) => {
delete config.rtlConfFilePath;
delete config.rtlPass;
delete config.multiPass;
delete config.multiPassHashed;
delete config.secret2FA;
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
return config;
// Clone before deleting: cookieValue is runtime-only, so mutating a caller's live
// appConfig would destroy SSO state with no way to restore it.
const sanitized = JSON.parse(JSON.stringify(config));
delete sanitized.rtlConfFilePath;
delete sanitized.rtlPass;
delete sanitized.multiPass;
delete sanitized.multiPassHashed;
delete sanitized.secret2FA;
// The SSO cookie is a live bearer credential; it must never leave the server.
if (sanitized.SSO) { delete sanitized.SSO.cookieValue; }
sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node));
return sanitized;
};
public addSecureData = (config: ApplicationConfig) => {
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
config.rtlPass = this.appConfig.rtlPass;
config.multiPassHashed = this.appConfig.multiPassHashed;
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath;
// Pin the hash only when the server holds one: on a default install's first boot the
// file already has multiPassHashed but the in-memory config does not, and pinning
// undefined would erase the only password from the file on save, bricking the boot.
if (this.appConfig.multiPassHashed) {
config.multiPassHashed = this.appConfig.multiPassHashed;
} else {
delete config.multiPassHashed;
}
// Deployment-level switches are pinned to server-held values: the settings API must
// not flip the authentication mode (disableAuth, SSO) or move SSO fields, the
// password policy, or the database location; no UI flow writes them. Pinning the
// whole SSO object also means a trimmed or missing SSO object can never wipe server
// state.
config.disableAuth = this.appConfig.disableAuth;
config.allowPasswordUpdate = this.appConfig.allowPasswordUpdate;
config.dbDirectoryPath = this.appConfig.dbDirectoryPath;
config.SSO = JSON.parse(JSON.stringify(this.appConfig.SSO || {}));
if (this.appConfig.multiPass) {
config.multiPass = this.appConfig.multiPass;
}
if (config.secret2FA === this.appConfig.secret2FA) {
// Restore the TOTP seed when the client omits it — and when it sends an empty seed
// while still claiming 2FA is on (an inconsistent pair no honest flow produces).
// The settings UI's enable flow sends a non-empty seed; its disable flow sends an
// empty seed with enable2FA false. Both are honored.
if (config.secret2FA === undefined || (config.secret2FA === '' && config.enable2FA)) {
config.secret2FA = this.appConfig.secret2FA;
}
// enable2FA derives from the seed, matching the boot-time derivation in config.ts,
// so the two fields can never diverge after a save.
config.enable2FA = !!config.secret2FA;
const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []);
config.nodes?.forEach((node) => {
const appConfigNode = appConfigNodes.get(node.index);
@ -112,7 +153,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err });
}
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options Set' });
return swapOptions;
};
@ -130,7 +171,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err });
}
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options Set' });
return boltzOptions;
};
@ -179,7 +220,7 @@ export class CommonService {
}
}
if (req.session.selectedNode) {
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode, data: req.session.selectedNode.authentication.options });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode });
}
return { status: 200, message: 'Updated Successfully' };
} catch (err) {
@ -247,7 +288,7 @@ export class CommonService {
form: ''
};
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode });
});
this.updateSelectedNodeOptions(req);
}
@ -364,10 +405,11 @@ export class CommonService {
this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) });
let newErrorObj = { statusCode: 500, message: '', error: '' };
if (err.code && err.code === 'ENOENT') {
// The absolute path stays in the server log above but is not echoed to clients.
newErrorObj = {
statusCode: 500,
message: 'No such file or directory ' + (err.path ? err.path : ''),
error: 'No such file or directory ' + (err.path ? err.path : '')
message: 'No such file or directory',
error: 'No such file or directory'
};
} else {
newErrorObj = {

View file

@ -284,7 +284,9 @@ export class ConfigService {
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err });
}
this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log';
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) });
// maskPasswords keeps paths visible for debugging while redacting credential
// fields such as lnApiPassword before they reach the log file.
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(this.common.nodes[idx])) });
const log_file = this.common.nodes[idx].settings.logFile;
if (fs.existsSync(log_file || '')) {
fs.writeFile((log_file || ''), '', () => { });

View file

@ -16,7 +16,7 @@ export const SECS_IN_YEAR = 31536000;
export const DEFAULT_INVOICE_EXPIRY = HOUR_SECONDS * 24 * 7;
export const VERSION = '0.15.9-beta';
export const VERSION = '0.15.10-beta';
export const API_URL = isDevMode() ? 'http://localhost:3000/rtl/api' : './api';

View file

@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import jwt from 'jsonwebtoken';
import * as otplib from 'otplib';
import { authenticateUser } from '../../backend/controllers/shared/authenticate.js';
import { Common } from '../../backend/utils/common.js';
const { authenticator } = otplib;
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
const PASSWORD_HASH = 'hashed-password';
const setupAppConfig = (enable2FA, secret2FA) => {
Common.appConfig = {
defaultNodeIndex: 0,
selectedNodeIndex: 0,
rtlConfFilePath: '',
dbDirectoryPath: '',
rtlPass: PASSWORD_HASH,
allowPasswordUpdate: true,
enable2FA: enable2FA,
secret2FA: secret2FA,
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' },
nodes: []
};
Common.selectedNode = null;
Common.nodes = [];
};
// failedLoginAttempts is module-level state in authenticate.js, keyed by the request IP
// from common.getRequestIP, which prefers x-forwarded-for (server/utils/common.ts).
// Unique IPs give each call a fresh counter; tests exercising the counter itself pass an
// explicit ip to share one key across calls.
let ipCounter = 0;
const nextIP = () => '10.0.0.' + (ipCounter = ipCounter + 1);
const mockRequest = ({ twoFAToken, ip, authToken, password } = {}) => {
const headers = { 'x-forwarded-for': ip || nextIP() };
if (authToken) { headers.authorization = 'Bearer ' + authToken; }
return {
body: { authenticateWith: 'PASSWORD', authenticationValue: password || PASSWORD_HASH, twoFAToken: twoFAToken },
session: {},
headers: headers,
connection: {},
socket: {}
};
};
const mockResponse = () => {
const res = { statusCode: null, body: null };
res.status = (code) => {
res.statusCode = code;
return { json: (body) => { res.body = body; } };
};
return res;
};
const mockSessionToken = () => jwt.sign({ user: 'NODE_USER' }, Common.secret_key);
test('rejects login without a 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
for (const missingToken of [undefined, '']) {
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: missingToken }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
}
});
test('rejects login with an invalid 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '000000' }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
});
test('rejects a non-string 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
// A JSON body can carry an array/object/number. otplib 12.0.1 coerces and rejects these
// (digit regex, then strict === against the string token), but the typeof guard keeps the
// rejection explicit and independent of otplib internals.
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: ['1', '2', '3', '4', '5', '6'] }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
});
test('accepts login with a valid 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: authenticator.generate(TOTP_SECRET) }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('accepts password-only re-authorization from an authenticated session when 2FA is enabled', () => {
// In-app re-authorization (e.g. the password prompt before on-chain sends) carries the
// session JWT via the auth interceptor; that session was itself minted after 2FA.
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined, authToken: mockSessionToken() }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('rejects a wrong password even with an authenticated session when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined, authToken: mockSessionToken(), password: 'wrong-hash' }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /Invalid Password/);
});
test('locks out after five failed 2FA attempts, even for a then-valid token', () => {
setupAppConfig(true, TOTP_SECRET);
const ip = nextIP(); // one shared counter key for every attempt in this test
for (let i = 0; i < 4; i++) {
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '000000', ip: ip }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
}
const fifth = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '000000', ip: ip }), fifth, null);
assert.equal(fifth.statusCode, 401);
assert.match(fifth.body.error, /locked/);
const sixth = mockResponse();
authenticateUser(mockRequest({ twoFAToken: authenticator.generate(TOTP_SECRET), ip: ip }), sixth, null);
assert.equal(sixth.statusCode, 401);
assert.match(sixth.body.error, /locked/);
});
test('accepts password-only login when 2FA is not configured', () => {
setupAppConfig(false, '');
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('accepts a stale token in the request when 2FA is not configured', () => {
// Pins an intentional behavior change: previously a non-empty twoFAToken with no
// configured secret was rejected (verifyToken short-circuits on the empty secret);
// with no 2FA configured the token is now ignored entirely.
setupAppConfig(false, '');
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '123456' }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('does not require a token when 2FA is disabled but a stale secret remains', () => {
// The login UI prompts only when enable2FA is set, so enforcing a token on a stale
// secret would lock the operator out of a UI that never asks for one.
setupAppConfig(false, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('does not enforce a token when 2FA is enabled without a secret', () => {
// Divergence is only reachable via a crafted settings update; a token could never
// verify against an empty secret, so enforcing would lock everyone out.
setupAppConfig(true, '');
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});

View file

@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Common } from '../../backend/utils/common.js';
test('maskPasswords masks TOTP and SSO cookie secrets along with passwords', () => {
const config = {
secret2FA: 'JBSWY3DPEHPK3PXP',
multiPassHashed: 'password-hash',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', cookieValue: 'live-sso-cookie' },
nodes: [{ index: 1, authentication: { lnApiPassword: 'eclair-pass', macaroonPath: '/macaroon/path' } }]
};
const masked = Common.maskPasswords(config);
assert.equal(masked.secret2FA, '*'.repeat(20));
assert.equal(masked.SSO.cookieValue, '*'.repeat(20));
assert.equal(masked.multiPassHashed, '*'.repeat(20));
assert.equal(masked.nodes[0].authentication.lnApiPassword, '*'.repeat(20));
// Paths are configuration, not secrets — they must stay visible for the settings UI.
assert.equal(masked.nodes[0].authentication.macaroonPath, '/macaroon/path');
assert.equal(masked.SSO.rtlCookiePath, '/cookie-path');
});
test('removeSecureData strips the SSO cookie along with the other secrets', () => {
const config = {
rtlConfFilePath: '/conf',
rtlPass: 'password-hash',
multiPassHashed: 'password-hash',
secret2FA: 'JBSWY3DPEHPK3PXP',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' },
nodes: [{ index: 1, authentication: { macaroonPath: '/macaroon/path', runeValue: 'rune', options: {} } }]
};
const cleaned = Common.removeSecureData(config);
assert.equal(cleaned.rtlConfFilePath, undefined);
assert.equal(cleaned.rtlPass, undefined);
assert.equal(cleaned.multiPassHashed, undefined);
assert.equal(cleaned.secret2FA, undefined);
assert.equal(cleaned.SSO.cookieValue, undefined);
// Non-secret SSO settings survive — the settings UI renders them.
assert.equal(cleaned.SSO.rtlCookiePath, '/cookie-path');
assert.equal(cleaned.nodes[0].authentication.macaroonPath, undefined);
});
test('removeSecureData does not mutate its input', () => {
// cookieValue is runtime-only: if a caller ever passes the live appConfig, an in-place
// delete would wipe SSO state with no way to restore it. The function must clone.
const config = { rtlPass: 'password-hash', SSO: { cookieValue: 'live-sso-cookie' }, nodes: [] };
Common.removeSecureData(config);
assert.equal(config.rtlPass, 'password-hash');
assert.equal(config.SSO.cookieValue, 'live-sso-cookie');
});
test('maskPasswords masks rtlPass and runeValue', () => {
const config = {
rtlPass: 'login-hash',
nodes: [{ index: 1, authentication: { runeValue: 'cln-rune' } }]
};
const masked = Common.maskPasswords(config);
assert.equal(masked.rtlPass, '*'.repeat(20));
assert.equal(masked.nodes[0].authentication.runeValue, '*'.repeat(20));
});
test('maskPasswords tolerates null values and numeric keys without skipping secrets', () => {
// Integer-like keys order first; the recursion must not clobber its own key list, and
// typeof null === 'object' must not send it into Object.keys(null).
const config = { '1': { nested: 'value' }, lnApiPassword: 'eclair-pass', nothing: null };
const masked = Common.maskPasswords(config);
assert.equal(masked.lnApiPassword, '*'.repeat(20));
assert.equal(masked.nothing, null);
assert.deepEqual(masked['1'], { nested: 'value' });
});
test('handleError does not echo the absolute file path to the caller', () => {
// The path belongs in the server log, not in the API response.
const err = Common.handleError({ code: 'ENOENT', path: '/secret/dir/RTL-Config.json' }, 'Test', 'Reading Config Error', { lnImplementation: 'LND', settings: {} });
assert.equal(err.error.includes('/secret/dir'), false);
assert.equal(err.message.includes('/secret/dir'), false);
});
test('handleError keeps the absolute path out of controller-wrapped errors too', () => {
// RTLConf handlers pass { statusCode, message, error: errRes } wrappers; the response
// must resolve to the caller's generic message, never the wrapped fs error's path.
const err = Common.handleError(
{ statusCode: 500, message: 'Reading File Error', error: { code: 'ENOENT', path: '/secret/dir/x.bak' } },
'Test', 'Reading File Error', { lnImplementation: 'LND', settings: {} }
);
assert.equal(err.error.includes('/secret/dir'), false);
assert.equal(err.message.includes('/secret/dir'), false);
});
test('maskPasswords masks every value under a headers key', () => {
// Header values are always credential carriers here (macaroon, rune, basic auth), and
// key-substring matching cannot catch them without also hiding *Path fields.
const config = {
authentication: {
macaroonPath: '/visible/path',
options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef', rune: 'cln-rune', authorization: 'Basic xyz' } }
}
};
const masked = Common.maskPasswords(config);
assert.equal(masked.authentication.options.headers['Grpc-Metadata-macaroon'], '*'.repeat(20));
assert.equal(masked.authentication.options.headers.rune, '*'.repeat(20));
assert.equal(masked.authentication.options.headers.authorization, '*'.repeat(20));
assert.equal(masked.authentication.macaroonPath, '/visible/path');
});
const seedAppConfig = () => {
Common.appConfig = {
defaultNodeIndex: 0,
selectedNodeIndex: 0,
rtlConfFilePath: '/conf',
dbDirectoryPath: '/db',
rtlPass: 'server-hash',
allowPasswordUpdate: true,
enable2FA: true,
secret2FA: 'server-seed',
disableAuth: false,
SSO: { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' },
nodes: []
};
Common.selectedNode = null;
Common.nodes = [];
};
test('addSecureData pins disableAuth and the SSO object to server-held values', () => {
// The settings API must not be able to flip the authentication mode or move SSO fields;
// client-supplied values for these are deployment-level switches, not settings.
seedAppConfig();
const config = Common.addSecureData({
disableAuth: true,
SSO: { rtlSSO: 1, rtlCookiePath: '/client-path', logoutRedirectLink: 'https://client', cookieValue: 'client-cookie' },
secret2FA: 'client-seed',
nodes: []
});
assert.equal(config.disableAuth, false);
assert.deepEqual(config.SSO, { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' });
// An explicit non-empty seed is the settings UI's enable flow and is honored.
assert.equal(config.secret2FA, 'client-seed');
assert.equal(config.enable2FA, true);
});
test('addSecureData restores an omitted TOTP seed and derives enable2FA from the seed', () => {
seedAppConfig();
const config = Common.addSecureData({ nodes: [] });
assert.equal(config.secret2FA, 'server-seed');
assert.equal(config.enable2FA, true);
});
test('addSecureData treats an empty seed with 2FA claimed on as an omission', () => {
// The pre-login config response shape carries secret2FA: ''; echoing it must not wipe
// the seed while enable2FA stays on.
seedAppConfig();
const config = Common.addSecureData({ secret2FA: '', enable2FA: true, nodes: [] });
assert.equal(config.secret2FA, 'server-seed');
assert.equal(config.enable2FA, true);
});
test('addSecureData honors an explicit seed wipe only when 2FA is disabled', () => {
// The settings UI's disable flow sends secret2FA: '' together with enable2FA: false.
seedAppConfig();
const config = Common.addSecureData({ secret2FA: '', enable2FA: false, nodes: [] });
assert.equal(config.secret2FA, '');
assert.equal(config.enable2FA, false);
});
test('addSecureData does not pin an undefined multiPassHashed over the persisted one', () => {
// First-boot state of a default install: the file already holds multiPassHashed (the
// boot converted it), but the in-memory appConfig still holds plaintext multiPass and
// no hash. Pinning undefined here would erase the only password from the file on save
// and brick the next boot.
seedAppConfig();
Common.appConfig.multiPassHashed = undefined;
Common.appConfig.multiPass = 'password';
const config = Common.addSecureData({ nodes: [] });
assert.equal(Object.prototype.hasOwnProperty.call(config, 'multiPassHashed'), false);
assert.equal(config.multiPass, 'password');
});
test('addSecureData pins multiPassHashed when the server holds one', () => {
seedAppConfig();
Common.appConfig.multiPassHashed = 'server-hash-value';
const config = Common.addSecureData({ multiPassHashed: 'client-value', nodes: [] });
assert.equal(config.multiPassHashed, 'server-hash-value');
});
test('addSecureData pins allowPasswordUpdate and dbDirectoryPath to server-held values', () => {
// allowPasswordUpdate is false precisely when the password is environment-managed, and
// dbDirectoryPath redirects the runtime database — neither is writable from the UI.
seedAppConfig();
Common.appConfig.allowPasswordUpdate = false;
Common.appConfig.dbDirectoryPath = '/server-db';
const config = Common.addSecureData({ allowPasswordUpdate: true, dbDirectoryPath: '/client-db', nodes: [] });
assert.equal(config.allowPasswordUpdate, false);
assert.equal(config.dbDirectoryPath, '/server-db');
});
test('maskPasswords masks bitcoind rpcauth', () => {
const config = { rpcauth: 'user:salt$hmac', rpcuser: 'user', rpcpassword: 'pass' };
const masked = Common.maskPasswords(config);
assert.equal(masked.rpcauth, '*'.repeat(20));
assert.equal(masked.rpcuser, '*'.repeat(20));
assert.equal(masked.rpcpassword, '*'.repeat(20));
});
test('maskPasswords does not mutate its input', () => {
// Masking a live object must not blank the credentials LN requests authenticate with.
const config = { authentication: { options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef' } } } };
Common.maskPasswords(config);
assert.equal(config.authentication.options.headers['Grpc-Metadata-macaroon'], 'deadbeef');
});

View file

@ -0,0 +1,62 @@
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 { getChannels } from '../../backend/controllers/eclair/channels.js';
// Eclair authenticates with HTTP basic auth, so the request options carry the node's
// lnApiPassword in the authorization header. A DEBUG log of the whole options object
// therefore writes a recoverable credential into the node log file.
const buildRequest = (logFile) => ({
session: {
selectedNode: {
index: 1,
lnNode: 'eclair-node',
lnImplementation: 'ECL',
authentication: {
options: {
url: '',
rejectUnauthorized: false,
json: true,
headers: { authorization: 'Basic ' + Buffer.from(':super-secret-password').toString('base64') }
}
},
settings: { lnServerUrl: 'http://127.0.0.1:1/', logLevel: 'DEBUG', logFile: logFile }
}
},
query: {}
});
const waitForLog = async (logFile) => {
// logger.log appends asynchronously; give it a few turns to flush.
for (let i = 0; i < 40; i++) {
const contents = readFileSync(logFile, 'utf-8');
if (contents.includes('Channels =>')) { return contents; }
await new Promise((resolve) => setTimeout(resolve, 25));
}
return readFileSync(logFile, 'utf-8');
};
test('getChannels does not write the eclair auth header to the node log at DEBUG level', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ecl-channels-'));
const logFile = join(tempDir, 'RTL-Node-1.log');
writeFileSync(logFile, '');
const req = buildRequest(logFile);
const res = { status: () => ({ json: () => { } }) };
try {
getChannels(req, res, () => { });
const contents = await waitForLog(logFile);
assert.ok(contents.includes('Channels =>'), 'expected the controller to have logged at DEBUG level');
assert.ok(!contents.includes('authorization'), 'auth header key must not reach the node log');
assert.ok(!contents.includes('super-secret-password'), 'lnApiPassword must not reach the node log');
assert.ok(!contents.includes(Buffer.from(':super-secret-password').toString('base64')), 'encoded credential must not reach the node log');
// The diagnostic value of the log — where the call went — is still there.
assert.ok(contents.includes('/channels'), 'request url should still be logged for diagnostics');
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
});

View file

@ -1,10 +1,10 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, sep } from 'node:path';
import test from 'node:test';
import { updateApplicationSettings } from '../../backend/controllers/shared/RTLConf.js';
import { updateApplicationSettings, updateNodeSettings, getFile } from '../../backend/controllers/shared/RTLConf.js';
import { Common } from '../../backend/utils/common.js';
import { WSServer } from '../../backend/utils/webSocketServer.js';
@ -137,3 +137,466 @@ test('updateApplicationSettings preserves indexed node auth and sanitizes only p
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings keeps the SSO cookie server-side without exposing or persisting it', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-sso-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
enable2FA: false,
allowPasswordUpdate: true,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }
});
// The request carries only what the sanitized client can have seen: no cookieValue.
// The server must re-attach it — a settings save must never wipe the live cookie —
// while keeping it out of both the response and the persisted file.
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
enable2FA: false,
allowPasswordUpdate: true,
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' }
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
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.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.SSO.rtlCookiePath, '/cookie-path');
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
assert.equal(fileConfig.SSO.cookieValue, undefined);
assert.equal(responseBody.SSO.cookieValue, undefined);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings restores omitted secret2FA and merges a trimmed SSO object', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-secrets-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
enable2FA: true,
allowPasswordUpdate: true,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
secret2FA: 'live-totp-seed',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example', cookieValue: 'live-sso-cookie' }
});
// Sanitized responses carry neither secret2FA nor cookieValue, so an echoing client
// omits both; a trimmed SSO object also lacks logoutRedirectLink. All three must
// survive the save server-side.
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
enable2FA: true,
allowPasswordUpdate: true,
SSO: { rtlSSO: 0 }
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(Common.appConfig.secret2FA, 'live-totp-seed');
assert.equal(Common.appConfig.enable2FA, true);
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.SSO.logoutRedirectLink, 'https://logout.example');
assert.equal(Common.appConfig.SSO.rtlSSO, 0);
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
assert.equal(fileConfig.SSO.cookieValue, undefined);
assert.equal(fileConfig.secret2FA, 'live-totp-seed');
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings tolerates a request body without an SSO object', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-nosso-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const requestBody = clone(oldConfig);
delete requestBody.SSO;
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
updateApplicationSettings(
{ body: requestBody, session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(typeof Common.appConfig.SSO, 'object');
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings leaves the runtime config untouched when the file write fails', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-writefail-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
secret2FA: 'live-totp-seed',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }
});
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
SSO: { rtlSSO: 0 },
nodes: [{ ...clone(oldConfig.nodes[0]), settings: { themeMode: 'NIGHT' } }]
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
// Both write paths must fail: a read-only dir defeats the temp-file write, and a
// read-only file defeats the in-place fallback.
chmodSync(confPath, 0o444);
chmodSync(tempDir, 0o555);
let responseStatus = null;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 500);
// The failed write must not have committed the prospective config in memory either.
assert.equal(Common.appConfig.secret2FA, 'live-totp-seed');
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.nodes[0].settings.themeMode, 'DAY');
// And the on-disk file still parses as the pre-call config.
const onDisk = JSON.parse(readFileSync(confPath, 'utf-8'));
assert.equal(onDisk.nodes.length, 1);
} finally {
clearInterval(WSServer.pingInterval);
chmodSync(confPath, 0o644);
chmodSync(tempDir, 0o755);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings preserves the config file mode across the atomic write', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-mode-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
chmodSync(confPath, 0o600); // operator-hardened; must not be silently downgraded
let responseStatus = null;
updateApplicationSettings(
{ body: clone(oldConfig), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(statSync(confPath).mode & 0o777, 0o600);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings falls back to an in-place write when the rename fails', () => {
// Single-file bind mounts and symlinks cannot be renamed over; the save must still work.
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-fallback-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
chmodSync(confPath, 0o600);
mkdirSync(confPath + '.tmp'); // forces the temp write to fail, exercising the fallback
let responseStatus = null;
updateApplicationSettings(
{ body: clone(oldConfig), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(statSync(confPath).mode & 0o777, 0o600); // in-place write keeps the inode
assert.deepEqual(JSON.parse(readFileSync(confPath, 'utf-8')).nodes.length, 1);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateNodeSettings pins channelBackupPath to the server-held value', () => {
// channelBackupPath anchors getFile's containment root; accepting it from the request
// would let the caller being contained choose the containment base.
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-nodesettings-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY', channelBackupPath: '/server/backups' }
}
]
};
try {
Common.appConfig = clone({ ...oldConfig, rtlConfFilePath: tempDir });
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus = null;
updateNodeSettings(
{
body: { settings: { themeMode: 'NIGHT', channelBackupPath: tempDir } },
session: { selectedNode: Common.nodes[0] }
},
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
const fileNode = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8')).nodes[0];
assert.equal(fileNode.settings.channelBackupPath, '/server/backups');
assert.equal(fileNode.settings.themeMode, 'NIGHT'); // other settings still merge
assert.equal(Common.nodes[0].settings.channelBackupPath, '/server/backups');
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('getFile contains caller paths to the channel backup directory', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-getfile-'));
const backupDir = join(tempDir, 'backups');
mkdirSync(backupDir);
writeFileSync(join(tempDir, 'secret.bak'), 'top-secret', 'utf-8');
writeFileSync(join(backupDir, 'channel-1x2x3.bak'), 'backup-data', 'utf-8');
const session = { selectedNode: { lnImplementation: 'LND', settings: { channelBackupPath: backupDir } } };
const mockRes = () => {
const res = { statusCode: null, body: null };
res.status = (code) => {
res.statusCode = code;
return { json: (body) => { res.body = body; } };
};
return res;
};
try {
// An escaping path is rejected before any read.
const rejected = mockRes();
getFile({ query: { path: join(tempDir, 'secret.bak') }, session }, rejected, null);
assert.equal(rejected.statusCode, 403);
// A contained path is served.
const served = mockRes();
await new Promise((resolve) => {
const res = { status: (code) => { served.statusCode = code; return { json: (body) => { served.body = body; resolve(); } }; } };
getFile({ query: { path: join(backupDir, 'channel-1x2x3.bak') }, session }, res, null);
});
assert.equal(served.statusCode, 200);
assert.equal(served.body, 'backup-data');
// A contained but missing file returns a path-free error (the ENOENT branch).
const missing = mockRes();
await new Promise((resolve) => {
const res = { status: (code) => { missing.statusCode = code; return { json: (body) => { missing.body = body; resolve(); } }; } };
getFile({ query: { path: join(backupDir, 'channel-missing.bak') }, session }, res, null);
});
assert.equal(missing.statusCode, 500);
assert.equal(JSON.stringify(missing.body).includes(backupDir), false);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});