mirror of
https://github.com/Ride-The-Lightning/RTL.git
synced 2026-08-13 12:33:07 +02:00
Compare commits
No commits in common. "master" and "v0.15.9" have entirely different histories.
35 changed files with 1595 additions and 2503 deletions
|
|
@ -1,98 +0,0 @@
|
|||
---
|
||||
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
122
CLAUDE.md
|
|
@ -1,122 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -53,10 +53,7 @@ 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 });
|
||||
}
|
||||
// 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 } });
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options });
|
||||
if (common.read_dummy_data) {
|
||||
common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { Common } from '../../utils/common.js';
|
|||
let options = null;
|
||||
const logger = Logger;
|
||||
const common = Common;
|
||||
export const getAliasForChannel = (selNode, channel, requestOptions) => {
|
||||
export const getAliasForChannel = (selNode, channel) => {
|
||||
const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : '';
|
||||
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(requestOptions).then((aliasBody) => {
|
||||
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(options).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,26 +30,18 @@ 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) {
|
||||
body.channels.forEach((channel) => {
|
||||
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);
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
}
|
||||
else {
|
||||
|
|
@ -74,32 +66,26 @@ export const getPendingChannels = (req, res, next) => {
|
|||
if (!body.total_limbo_balance) {
|
||||
body.total_limbo_balance = 0;
|
||||
}
|
||||
const selNode = req.session.selectedNode;
|
||||
const { qs: _qs, ...requestOptions } = options;
|
||||
const getPendingAliasesTasks = [];
|
||||
const promises = [];
|
||||
if (body.pending_open_channels && body.pending_open_channels.length > 0) {
|
||||
body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) {
|
||||
body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
if (body.pending_closing_channels && body.pending_closing_channels.length > 0) {
|
||||
body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
if (body.waiting_close_channels && body.waiting_close_channels.length > 0) {
|
||||
body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
}).catch((errRes) => {
|
||||
const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode);
|
||||
|
|
@ -116,23 +102,15 @@ export const getClosedChannels = (req, res, next) => {
|
|||
options.qs = req.query;
|
||||
request(options).then((body) => {
|
||||
if (body.channels && body.channels.length > 0) {
|
||||
body.channels.forEach((channel) => {
|
||||
return Promise.all(body.channels?.map((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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
}
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { Common } from '../../utils/common.js';
|
|||
let options = null;
|
||||
const logger = Logger;
|
||||
const common = Common;
|
||||
export const getAliasFromPubkey = (selNode, pubkey, requestOptions) => {
|
||||
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(requestOptions).then((res) => {
|
||||
export const getAliasFromPubkey = (selNode, pubkey) => {
|
||||
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(options).then((res) => {
|
||||
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias });
|
||||
return res.node.alias;
|
||||
}).
|
||||
|
|
@ -83,25 +83,19 @@ 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) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
}
|
||||
else {
|
||||
|
|
@ -151,21 +145,14 @@ export const getAliasesForPubkeys = (req, res, next) => {
|
|||
}
|
||||
if (req.query.pubkeys) {
|
||||
const pubkeyArr = req.query.pubkeys.split(',');
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
});
|
||||
}
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import * as fs from 'fs';
|
||||
import { resolve, sep } from 'path';
|
||||
import { sep } from 'path';
|
||||
import ini from 'ini';
|
||||
import parseHocon from 'hocon-parser';
|
||||
import request from '../../utils/request.js';
|
||||
|
|
@ -76,23 +76,7 @@ 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 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';
|
||||
}
|
||||
const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.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) => {
|
||||
|
|
@ -105,8 +89,7 @@ export const getFile = (req, res, next) => {
|
|||
return res.status(err.statusCode).json({ message: err.error, error: err.error });
|
||||
}
|
||||
else {
|
||||
// File contents can carry node credentials; never write them to the log.
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received' });
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data });
|
||||
res.status(200).json(data);
|
||||
}
|
||||
});
|
||||
|
|
@ -126,6 +109,7 @@ 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;
|
||||
|
|
@ -217,12 +201,7 @@ export const updateNodeSettings = (req, res, next) => {
|
|||
const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8'));
|
||||
const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index));
|
||||
if (node && node.settings) {
|
||||
// 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;
|
||||
|
|
@ -241,9 +220,7 @@ 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;
|
||||
|
|
@ -304,7 +281,7 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode)));
|
||||
runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes];
|
||||
}
|
||||
const newAppConfig = JSON.parse(JSON.stringify({
|
||||
common.appConfig = JSON.parse(JSON.stringify({
|
||||
...runtimeConfig,
|
||||
selectedNodeIndex: config.selectedNodeIndex !== undefined ?
|
||||
config.selectedNodeIndex : common.appConfig.selectedNodeIndex,
|
||||
|
|
@ -315,42 +292,21 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
rtlConfFilePath: common.appConfig.rtlConfFilePath,
|
||||
rtlPass: common.appConfig.rtlPass
|
||||
}));
|
||||
const fileConfig = JSON.parse(JSON.stringify(newAppConfig));
|
||||
const fileConfig = JSON.parse(JSON.stringify(common.appConfig));
|
||||
delete fileConfig.selectedNodeIndex;
|
||||
delete fileConfig.enable2FA;
|
||||
delete fileConfig.allowPasswordUpdate;
|
||||
delete fileConfig.rtlConfFilePath;
|
||||
delete fileConfig.rtlPass;
|
||||
delete fileConfig.multiPass;
|
||||
// Runtime-only SSO bearer; must not be persisted with the config.
|
||||
if (fileConfig.SSO) {
|
||||
delete fileConfig.SSO.cookieValue;
|
||||
}
|
||||
fileConfig.nodes?.forEach((node) => {
|
||||
delete node.authentication?.options;
|
||||
delete node.authentication?.runeValue;
|
||||
});
|
||||
// Persist atomically (temp file + rename, so a mid-write failure cannot truncate the
|
||||
// config) and only then adopt the new runtime config, so a failed write leaves the
|
||||
// process on the old one. The temp file inherits the existing file's mode so a
|
||||
// hardened 0600 is not silently downgraded; a fresh file gets 0600. Symlinks and
|
||||
// single-file bind mounts cannot be renamed over — fall back to an in-place write,
|
||||
// which preserves inode and mode.
|
||||
const tempConfigFile = RTLConfFile + '.tmp';
|
||||
try {
|
||||
fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
fs.chmodSync(tempConfigFile, fs.existsSync(RTLConfFile) ? (fs.statSync(RTLConfFile).mode & 0o777) : 0o600);
|
||||
fs.renameSync(tempConfigFile, RTLConfFile);
|
||||
}
|
||||
catch {
|
||||
fs.rmSync(tempConfigFile, { force: true, recursive: true });
|
||||
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
}
|
||||
common.appConfig = newAppConfig;
|
||||
// removeSecureData clones, so the runtime config is untouched; it strips rtlPass,
|
||||
// the TOTP seed, the SSO cookie and all per-node credentials symmetrically.
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.removeSecureData(newAppConfig) });
|
||||
res.status(201).json(common.removeSecureData(newAppConfig));
|
||||
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));
|
||||
}
|
||||
catch (errRes) {
|
||||
const errMsg = 'Update Default Node Error';
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ 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))) {
|
||||
|
|
@ -48,21 +45,6 @@ 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..' });
|
||||
|
|
@ -102,15 +84,8 @@ export const authenticateUser = (req, res, next) => {
|
|||
const failed = getFailedInfo(reqIP, currentTime);
|
||||
const password = authenticationValue;
|
||||
if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) {
|
||||
// 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)) {
|
||||
if (twoFAToken && twoFAToken !== '') {
|
||||
if (!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;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
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);
|
||||
// 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.post('/reset', resetPassword);
|
||||
router.get('/logout', logoutUser);
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -22,37 +22,22 @@ 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) => {
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
};
|
||||
return maskRecursive(masked);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
this.removeAuthSecureData = (node) => {
|
||||
if (node.authentication) {
|
||||
|
|
@ -65,55 +50,25 @@ export class CommonService {
|
|||
return node;
|
||||
};
|
||||
this.removeSecureData = (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;
|
||||
delete config.rtlConfFilePath;
|
||||
delete config.rtlPass;
|
||||
delete config.multiPass;
|
||||
delete config.multiPassHashed;
|
||||
delete config.secret2FA;
|
||||
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
|
||||
return config;
|
||||
};
|
||||
this.addSecureData = (config) => {
|
||||
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
|
||||
config.rtlPass = this.appConfig.rtlPass;
|
||||
// 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 || {}));
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath;
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
// 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)) {
|
||||
if (config.secret2FA === this.appConfig.secret2FA) {
|
||||
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);
|
||||
|
|
@ -148,7 +103,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 Set' });
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions });
|
||||
return swapOptions;
|
||||
};
|
||||
this.getBoltzServerOptions = (req) => {
|
||||
|
|
@ -166,7 +121,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 Set' });
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions });
|
||||
return boltzOptions;
|
||||
};
|
||||
this.getOptions = (req) => {
|
||||
|
|
@ -212,7 +167,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 });
|
||||
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 });
|
||||
}
|
||||
return { status: 200, message: 'Updated Successfully' };
|
||||
}
|
||||
|
|
@ -282,7 +237,7 @@ export class CommonService {
|
|||
form: ''
|
||||
};
|
||||
}
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode });
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options });
|
||||
});
|
||||
this.updateSelectedNodeOptions(req);
|
||||
}
|
||||
|
|
@ -390,11 +345,10 @@ 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',
|
||||
error: 'No such file or directory'
|
||||
message: 'No such file or directory ' + (err.path ? err.path : ''),
|
||||
error: 'No such file or directory ' + (err.path ? err.path : '')
|
||||
};
|
||||
}
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -302,9 +302,7 @@ 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';
|
||||
// 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])) });
|
||||
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
fs.writeFile((log_file || ''), '', () => { });
|
||||
|
|
|
|||
|
|
@ -74,9 +74,6 @@ 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.
|
||||
|
||||
|
|
@ -121,7 +118,6 @@ 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
|
||||
```
|
||||
|
||||
|
|
@ -132,94 +128,6 @@ 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
|
||||
|
|
@ -272,7 +180,3 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
#!/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}"
|
||||
|
|
@ -17,9 +17,6 @@ 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
|
||||
|
|
@ -288,7 +285,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.10}
|
||||
image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.8}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
rtl-config-init:
|
||||
|
|
@ -314,87 +311,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
# 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
#!/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
1
frontend/main.483124dd4b12e339.js
Normal file
1
frontend/main.483124dd4b12e339.js
Normal file
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
1712
package-lock.json
generated
File diff suppressed because it is too large
Load diff
34
package.json
34
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "rtl",
|
||||
"version": "0.15.10-beta",
|
||||
"version": "0.15.9-beta",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -16,8 +16,7 @@
|
|||
"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",
|
||||
"testbackend": "node --test test/backend/*.test.mjs",
|
||||
"test": "npm run buildbackend && npm run testbackend && ng test --watch=false --browsers=ChromeHeadless",
|
||||
"test": "ng test --watch=false --browsers=ChromeHeadless",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"private": true,
|
||||
|
|
@ -27,7 +26,7 @@
|
|||
"@swimlane/ngx-charts": "23.1.0",
|
||||
"angular-user-idle": "4.0.0",
|
||||
"atob": "2.1.2",
|
||||
"axios": "1.18.1",
|
||||
"axios": "1.16.0",
|
||||
"buffer": "6.0.3",
|
||||
"cookie-parser": "1.4.7",
|
||||
"csrf-csrf": "4.0.3",
|
||||
|
|
@ -55,20 +54,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.27",
|
||||
"@angular/animations": "20.3.26",
|
||||
"@angular/build": "20.3.32",
|
||||
"@angular/cdk": "20.2.14",
|
||||
"@angular/cli": "20.3.32",
|
||||
"@angular/common": "20.3.27",
|
||||
"@angular/compiler": "20.3.27",
|
||||
"@angular/compiler-cli": "20.3.27",
|
||||
"@angular/core": "20.3.27",
|
||||
"@angular/common": "20.3.26",
|
||||
"@angular/compiler": "20.3.26",
|
||||
"@angular/compiler-cli": "20.3.26",
|
||||
"@angular/core": "20.3.26",
|
||||
"@angular/flex-layout": "15.0.0-beta.42",
|
||||
"@angular/forms": "20.3.27",
|
||||
"@angular/forms": "20.3.26",
|
||||
"@angular/material": "20.2.14",
|
||||
"@angular/platform-browser": "20.3.27",
|
||||
"@angular/platform-browser-dynamic": "20.3.27",
|
||||
"@angular/router": "20.3.27",
|
||||
"@angular/platform-browser": "20.3.26",
|
||||
"@angular/platform-browser-dynamic": "20.3.26",
|
||||
"@angular/router": "20.3.26",
|
||||
"@eslint/eslintrc": "3.3.3",
|
||||
"@fortawesome/angular-fontawesome": "4.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "7.1.0",
|
||||
|
|
@ -77,10 +76,10 @@
|
|||
"@ngrx/store-devtools": "21.0.1",
|
||||
"@types/jasmine": "5.1.15",
|
||||
"@types/node": "20.19.30",
|
||||
"@typescript-eslint/eslint-plugin": "8.65.0",
|
||||
"@typescript-eslint/parser": "8.65.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint": "9.39.5",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-plugin-deprecation": "3.0.0",
|
||||
"jasmine-core": "5.13.0",
|
||||
"jasmine-spec-reporter": "7.0.0",
|
||||
|
|
@ -90,7 +89,8 @@
|
|||
"karma-jasmine": "5.1.0",
|
||||
"karma-jasmine-html-reporter": "2.1.0",
|
||||
"material-icons": "1.13.14",
|
||||
"nodemon": "3.1.14",
|
||||
"nodemon": "3.1.11",
|
||||
"protractor": "7.0.0",
|
||||
"roboto-fontface": "0.10.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.8.3"
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -55,10 +55,7 @@ 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 });
|
||||
}
|
||||
// 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 } });
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options });
|
||||
if (common.read_dummy_data) {
|
||||
common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); });
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ let options = null;
|
|||
const logger: LoggerService = Logger;
|
||||
const common: CommonService = Common;
|
||||
|
||||
export const getAliasForChannel = (selNode: SelectedNode, channel, requestOptions) => {
|
||||
export const getAliasForChannel = (selNode: SelectedNode, channel) => {
|
||||
const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : '';
|
||||
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(requestOptions).then((aliasBody) => {
|
||||
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(options).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,23 +31,20 @@ 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) {
|
||||
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 }); }
|
||||
}
|
||||
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 });
|
||||
});
|
||||
} else {
|
||||
body.channels = [];
|
||||
|
|
@ -70,30 +67,27 @@ export const getPendingChannels = (req, res, next) => {
|
|||
if (!body.total_limbo_balance) {
|
||||
body.total_limbo_balance = 0;
|
||||
}
|
||||
const selNode = req.session.selectedNode;
|
||||
const { qs: _qs, ...requestOptions } = options;
|
||||
const getPendingAliasesTasks = [];
|
||||
const promises = [];
|
||||
if (body.pending_open_channels && body.pending_open_channels.length > 0) {
|
||||
body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) {
|
||||
body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
if (body.pending_closing_channels && body.pending_closing_channels.length > 0) {
|
||||
body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
if (body.waiting_close_channels && body.waiting_close_channels.length > 0) {
|
||||
body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
|
||||
body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
|
||||
}
|
||||
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 }); }
|
||||
}
|
||||
});
|
||||
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 });
|
||||
});
|
||||
}).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 });
|
||||
|
|
@ -108,20 +102,17 @@ export const getClosedChannels = (req, res, next) => {
|
|||
options.qs = req.query;
|
||||
request(options).then((body) => {
|
||||
if (body.channels && body.channels.length > 0) {
|
||||
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 }); }
|
||||
}
|
||||
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 });
|
||||
});
|
||||
} else {
|
||||
body.channels = [];
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ let options = null;
|
|||
const logger: LoggerService = Logger;
|
||||
const common: CommonService = Common;
|
||||
|
||||
export const getAliasFromPubkey = (selNode: SelectedNode, pubkey, requestOptions) => {
|
||||
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(requestOptions).then((res) => {
|
||||
export const getAliasFromPubkey = (selNode: SelectedNode, pubkey) => {
|
||||
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
|
||||
return request(options).then((res) => {
|
||||
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias });
|
||||
return res.node.alias;
|
||||
}).
|
||||
|
|
@ -80,23 +80,20 @@ 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) {
|
||||
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 {
|
||||
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 = typeof values[i] === 'string' ? values[i] : 'Unknown';
|
||||
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 (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 }); }
|
||||
}
|
||||
});
|
||||
}).
|
||||
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 });
|
||||
});
|
||||
} else {
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes Received', data: body });
|
||||
return res.status(200).json(body);
|
||||
|
|
@ -141,19 +138,15 @@ 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(',');
|
||||
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 }); }
|
||||
}
|
||||
});
|
||||
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 });
|
||||
});
|
||||
} else {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import * as fs from 'fs';
|
||||
import { resolve, sep } from 'path';
|
||||
import { sep } from 'path';
|
||||
import ini from 'ini';
|
||||
import parseHocon from 'hocon-parser';
|
||||
import request from '../../utils/request.js';
|
||||
|
|
@ -81,22 +81,7 @@ 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 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';
|
||||
}
|
||||
const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.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) => {
|
||||
|
|
@ -106,8 +91,7 @@ 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 {
|
||||
// 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' });
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data });
|
||||
res.status(200).json(data);
|
||||
}
|
||||
});
|
||||
|
|
@ -128,6 +112,7 @@ 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;
|
||||
|
|
@ -220,12 +205,7 @@ export const updateNodeSettings = (req, res, next) => {
|
|||
const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8'));
|
||||
const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index));
|
||||
if (node && node.settings) {
|
||||
// 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;
|
||||
|
|
@ -242,9 +222,7 @@ 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;
|
||||
|
|
@ -303,7 +281,7 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode)));
|
||||
runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes];
|
||||
}
|
||||
const newAppConfig = JSON.parse(JSON.stringify({
|
||||
common.appConfig = JSON.parse(JSON.stringify({
|
||||
...runtimeConfig,
|
||||
selectedNodeIndex: config.selectedNodeIndex !== undefined ?
|
||||
config.selectedNodeIndex : common.appConfig.selectedNodeIndex,
|
||||
|
|
@ -314,39 +292,21 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
rtlConfFilePath: common.appConfig.rtlConfFilePath,
|
||||
rtlPass: common.appConfig.rtlPass
|
||||
}));
|
||||
const fileConfig = JSON.parse(JSON.stringify(newAppConfig));
|
||||
const fileConfig = JSON.parse(JSON.stringify(common.appConfig));
|
||||
delete fileConfig.selectedNodeIndex;
|
||||
delete fileConfig.enable2FA;
|
||||
delete fileConfig.allowPasswordUpdate;
|
||||
delete fileConfig.rtlConfFilePath;
|
||||
delete fileConfig.rtlPass;
|
||||
delete fileConfig.multiPass;
|
||||
// Runtime-only SSO bearer; must not be persisted with the config.
|
||||
if (fileConfig.SSO) { delete fileConfig.SSO.cookieValue; }
|
||||
fileConfig.nodes?.forEach((node) => {
|
||||
delete node.authentication?.options;
|
||||
delete node.authentication?.runeValue;
|
||||
});
|
||||
// Persist atomically (temp file + rename, so a mid-write failure cannot truncate the
|
||||
// config) and only then adopt the new runtime config, so a failed write leaves the
|
||||
// process on the old one. The temp file inherits the existing file's mode so a
|
||||
// hardened 0600 is not silently downgraded; a fresh file gets 0600. Symlinks and
|
||||
// single-file bind mounts cannot be renamed over — fall back to an in-place write,
|
||||
// which preserves inode and mode.
|
||||
const tempConfigFile = RTLConfFile + '.tmp';
|
||||
try {
|
||||
fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
fs.chmodSync(tempConfigFile, fs.existsSync(RTLConfFile) ? (fs.statSync(RTLConfFile).mode & 0o777) : 0o600);
|
||||
fs.renameSync(tempConfigFile, RTLConfFile);
|
||||
} catch {
|
||||
fs.rmSync(tempConfigFile, { force: true, recursive: true });
|
||||
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
}
|
||||
common.appConfig = newAppConfig;
|
||||
// removeSecureData clones, so the runtime config is untouched; it strips rtlPass,
|
||||
// the TOTP seed, the SSO cookie and all per-node credentials symmetrically.
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.removeSecureData(newAppConfig) });
|
||||
res.status(201).json(common.removeSecureData(newAppConfig));
|
||||
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));
|
||||
} catch (errRes) {
|
||||
const errMsg = 'Update Default Node Error';
|
||||
const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode);
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@ 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 };
|
||||
|
|
@ -52,21 +49,6 @@ 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..' });
|
||||
|
|
@ -98,15 +80,8 @@ export const authenticateUser = (req, res, next) => {
|
|||
const failed = getFailedInfo(reqIP, currentTime);
|
||||
const password = authenticationValue;
|
||||
if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) {
|
||||
// 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)) {
|
||||
if (twoFAToken && twoFAToken !== '') {
|
||||
if (!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;
|
||||
|
|
|
|||
|
|
@ -1,15 +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);
|
||||
// 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.post('/reset', resetPassword);
|
||||
router.get('/logout', logoutUser);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -27,37 +27,23 @@ export class CommonService {
|
|||
constructor() {}
|
||||
|
||||
public maskPasswords = (obj) => {
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
};
|
||||
return maskRecursive(masked);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
public removeAuthSecureData = (node: SelectedNode) => {
|
||||
|
|
@ -72,53 +58,26 @@ export class CommonService {
|
|||
};
|
||||
|
||||
public removeSecureData = (config: ApplicationConfig) => {
|
||||
// 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;
|
||||
delete config.rtlConfFilePath;
|
||||
delete config.rtlPass;
|
||||
delete config.multiPass;
|
||||
delete config.multiPassHashed;
|
||||
delete config.secret2FA;
|
||||
config.nodes?.forEach((node) => this.removeAuthSecureData(node));
|
||||
return config;
|
||||
};
|
||||
|
||||
public addSecureData = (config: ApplicationConfig) => {
|
||||
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
|
||||
config.rtlPass = this.appConfig.rtlPass;
|
||||
// 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 || {}));
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath;
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
// 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)) {
|
||||
if (config.secret2FA === this.appConfig.secret2FA) {
|
||||
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);
|
||||
|
|
@ -153,7 +112,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 Set' });
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions });
|
||||
return swapOptions;
|
||||
};
|
||||
|
||||
|
|
@ -171,7 +130,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 Set' });
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions });
|
||||
return boltzOptions;
|
||||
};
|
||||
|
||||
|
|
@ -220,7 +179,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 });
|
||||
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 });
|
||||
}
|
||||
return { status: 200, message: 'Updated Successfully' };
|
||||
} catch (err) {
|
||||
|
|
@ -288,7 +247,7 @@ export class CommonService {
|
|||
form: ''
|
||||
};
|
||||
}
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode });
|
||||
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options });
|
||||
});
|
||||
this.updateSelectedNodeOptions(req);
|
||||
}
|
||||
|
|
@ -405,11 +364,10 @@ 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',
|
||||
error: 'No such file or directory'
|
||||
message: 'No such file or directory ' + (err.path ? err.path : ''),
|
||||
error: 'No such file or directory ' + (err.path ? err.path : '')
|
||||
};
|
||||
} else {
|
||||
newErrorObj = {
|
||||
|
|
|
|||
|
|
@ -284,9 +284,7 @@ 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';
|
||||
// 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])) });
|
||||
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
fs.writeFile((log_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.10-beta';
|
||||
export const VERSION = '0.15.9-beta';
|
||||
|
||||
export const API_URL = isDevMode() ? 'http://localhost:3000/rtl/api' : './api';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
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');
|
||||
});
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
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');
|
||||
});
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, sep } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { updateApplicationSettings, updateNodeSettings, getFile } from '../../backend/controllers/shared/RTLConf.js';
|
||||
import { updateApplicationSettings } from '../../backend/controllers/shared/RTLConf.js';
|
||||
import { Common } from '../../backend/utils/common.js';
|
||||
import { WSServer } from '../../backend/utils/webSocketServer.js';
|
||||
|
||||
|
|
@ -137,466 +137,3 @@ 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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue