Compare commits

...

107 commits

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

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

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

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

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

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

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

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

* Point CLAUDE.md at the BTCPay SSO harness

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

* Update project dependencies to resolve Dependabot security alerts

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

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

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

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

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

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

* Harden login request validation (#1654)

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

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

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

* Reduce exposure of authentication secrets in logs and config responses

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

* Harden redaction helpers and secret restore paths

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

* Contain backup file reads and harden config persistence

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

* Update Angular framework packages to 20.3.27 (#1661)

* Update Angular framework packages to 20.3.27

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

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

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

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

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

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

Fixes #1630

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

* Improve release notes entry to cover full PR scope

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

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

* Stop logging the eclair auth header at DEBUG level

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

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

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

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

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

---------

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

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

Keeps the details docker/README.md does not cover: `docker compose up -d rtl`
silently restarts stopped dependencies (reconnecting a peer you stopped
mid-test), and the API handshake for verification scripts — base href /rtl,
CSRF token echoed as x-xsrf-token, SHA256-hashed password, and cln/getinfo
before any CLN channel endpoint.
2026-07-25 10:13:59 -07:00
saubyk
500bd31be5 Fill in PR number in release note (#1646) 2026-07-19 22:01:23 -07:00
saubyk
a4246c4fe6 Link the release notes folder from the README 2026-07-19 22:01:23 -07:00
saubyk
0a2c591177 Add release note for multi-node config auth-preservation fix (#1645) 2026-07-19 22:01:23 -07:00
Cosimo Ricciardi
a11179085b Add defensive guards for multi-node config updates 2026-07-19 22:01:23 -07:00
Cosimo Ricciardi
18a40ed183 Add defensive fallback for node map initialization 2026-07-19 22:01:23 -07:00
Cosimo Ricciardi
df52b9b77f Add defensive config handling for multi-node settings 2026-07-19 22:01:23 -07:00
Cosimo Ricciardi
bd0f515a6e Add defensive auth guards and cleanup iteration usage 2026-07-19 22:01:23 -07:00
Cosimo Ricciardi
40bd25d921 Address multi-node config auth preservation review 2026-07-19 22:01:23 -07:00
Cosimo Ricciardi
fa65568914 Prevent multi-node config overwrite during application settings updates 2026-07-19 22:01:23 -07:00
saubyk
fceb59bfed Remove deprecated outgoing_chan_id from QueryRoutes
The singular outgoing_chan_id query parameter on LND's QueryRoutes is
deprecated as of lnd 0.20.0 in favor of the plural outgoing_chan_ids.
The code path was unreachable in RTL anyway: no caller of the
GetQueryRoutes action ever populated outgoingChanId, so the query
parameter was never sent. Drop the unused field, the effect's
conditional URL builder, and the server-side passthrough.
2026-07-19 22:01:23 -07:00
saubyk
48b71d49e3 Address review: soften parity comment, guard async verify double-click
Two non-blocking review points on #1644:

- createHmacKey's comment claimed "exact parity" with otplib, but
  otplib's totpPadSecret under-repeats to 18 bytes for 1- and 9-byte
  secrets where this service pads to 20. Unreachable in RTL (secrets are
  always 10 bytes from generateSecret, field is read-only), and
  replicating the otplib bug has negative value - so the comment is
  corrected to state parity holds for the 10-byte secrets used here
  rather than universally.

- onVerifyToken's token check is now async, so two fast clicks on Verify
  could dispatch updateApplicationSettings twice (was synchronous
  before). Payload is idempotent so it was harmless, but guarded with an
  in-flight flag to restore the single-dispatch behavior.

Frontend/backend artifacts rebuilt; TOTP spec and lint pass.
2026-07-19 22:01:23 -07:00
saubyk
3082d9a1af Fill in PR number in release note (#1644) 2026-07-19 22:01:23 -07:00
saubyk
c5dc49711c Drop crypto-browserify polyfills by moving 2FA TOTP to WebCrypto
The frontend build pulled in crypto-browserify, stream-browserify and
vm-browserify (via tsconfig paths) only because otplib's
@otplib/plugin-crypto requires Node's crypto. That chain carried the
last production npm audit findings - the elliptic advisory
(GHSA-848j-6mx2-7j84, no fixed release) plus browserify-sign/create-ecdh
(issue #1634, item 3).

The two-factor-auth settings dialog is the only browser consumer of
otplib. It now uses a small WebCrypto TOTP service
(src/app/shared/services/totp.service.ts, RFC 6238: HMAC-SHA1, 6 digits,
30s step) instead, so otplib is no longer bundled and the three
polyfills plus their tsconfig path mappings are removed.

The backend still verifies login tokens with otplib, so the new service
must match it exactly - verified byte-for-byte against otplib and the
RFC 6238 test vectors (generateSecret/keyuri/generate/check parity).
Existing authenticator enrollments keep working. token check() is now
async (WebCrypto's digest is promise-based); the dialog's verify handler
was updated to match, and the value was never used for control flow.

Production npm audit now reports zero vulnerabilities (from 13, incl. 2
critical, at the start of this cleanup series). Verified on the docker
fixture: enrolled a 2FA secret from the new service, confirmed the
backend otplib accepts a token it produces at login, rejected
wrong/absent tokens. Unit spec covers RFC 6238 vectors, keyuri parity
and base32 round-trip; both API suites and the full frontend suite (204
specs) pass.
2026-07-19 22:01:23 -07:00
saubyk
4276a03463 Address review: document session-bound CSRF implications for API consumers 2026-07-19 22:01:23 -07:00
saubyk
030592ac23 Address review: fix logout -> re-login under session-bound CSRF tokens
Session-bound tokens broke re-login after logout: logoutUser destroys
the session, but the SPA navigated to the login page without a
document reload, so the surviving _csrf/XSRF-TOKEN cookies stayed
bound to the destroyed session id and the next login POST failed with
403 until a manual refresh. Hit both manual logout and the idle-timer
auto-logout.

Two coordinated fixes:

1. Frontend: the logout effect now performs a full document navigation
   to the login page (after the server logout completes, so the
   request is not aborted by the reload), which re-runs the handshake
   and mints a token bound to the fresh session. The logout reason
   previously travelled on the NgRx action stream, which cannot
   survive a reload - it is now handed over via sessionStorage (set
   after clearAll) and picked up and cleared by the login component.
   The SSO branch is unchanged (it already left the document).

2. Backend: the EBADCSRFTOKEN error path now re-mints the token for
   the current session before responding 403, so any client holding a
   stale token (e.g. after a server restart rotates the boot secret)
   self-heals on retry instead of looping on 403.

Verified on the fixture: reviewer's repro now shows login 200 ->
logout 200 -> stale-token login 403 (binding intact) with re-minted
cookies on the 403 -> retry 200; and the reload path (fresh GET /
after logout, what the full navigation does) logs in on the first
attempt. Both API suites, the CSRF battery, rtl.effects specs and the
full frontend suite pass; frontend and backend artifacts rebuilt.
2026-07-19 22:01:23 -07:00
saubyk
0af050a6b2 Fill in PR number in release note (#1643) 2026-07-19 22:01:23 -07:00
saubyk
e617fbb561 Replace deprecated csurf with csrf-csrf
csurf has been deprecated since 2022 and pins an old cookie release
with a known advisory; npm's only fix is a downgrade (issue #1634,
item 2). csrf-csrf v4 implements the same double-submit-cookie pattern
with an HMAC-signed, session-bound token keyed on the existing boot
secret (common.secret_key).

The frontend contract is unchanged: the token still arrives via the
XSRF-TOKEN cookie/header and is echoed as x-xsrf-token (all token
sources csurf accepted are still read), the signed cookie keeps the
_csrf name (now httpOnly, secure:false to match the session cookie on
plain-HTTP deployments), doubleCsrfProtection attaches req.csrfToken
so app.ts keeps working, and the error code is EBADCSRFTOKEN - already
handled in app.ts. The websocket upgrade check in authCheck.ts now
routes through the shared middleware; upgrade requests are GETs, so
its pass-through semantics are unchanged.

One fix this surfaced: app.ts called req.csrfToken() twice (cookie and
header). Under csurf every token validated against a stable secret;
under csrf-csrf each first-visit call mints a new token, desyncing the
XSRF-TOKEN cookie from the _csrf cookie it must equal. The token is
now generated once per request.

Tokens are session-bound, so a token stolen from one session no longer
validates in another - a check csurf's cookie mode did not perform.

Production npm audit drops from 6 low findings to 4, all in the
crypto-browserify/elliptic chain tracked in #1634.

Verified against the docker regtest fixture: both API suites (43
checks across LND, CLN and Eclair) plus a dedicated CSRF battery -
valid-token auth, missing token 403, garbage token 403, cross-session
replay 403, token stability across requests, the XSRF-TOKEN response
header for Quickpay, and the websocket handshake. Lint and build are
clean.
2026-07-19 22:01:23 -07:00
saubyk
6e61d1b759 Address review: coerce timeout_seconds to a positive finite number
The transport timeout added for the sendPayment race was derived from
req.body.timeout_seconds, which was only guarded by "|| 600": a
non-numeric value became NaN, which axios treats as no timeout,
silently dropping the transport ceiling for this endpoint.

timeout_seconds is now coerced to a positive finite number (falling
back to 600), which also normalizes the value sent to LND. Large
values are intentionally not capped: the transport bound must stay
above LND's own timeout_seconds bound or the race the margin fixes
would return.

Verified on the fixture: a payment sent with timeout_seconds "abc"
falls back to 600 and completes; the write suite re-passes.
2026-07-19 22:01:23 -07:00
saubyk
89b6313a5b Address review: avoid the sendPayment timeout race, keep paymentLookup bounded
sendPayment's timeout_seconds defaults to 600, equal to the wrapper's
600 s transport bound, so the transport timer (started first) would
win the race and surface ECONNABORTED instead of LND's clean
FAILURE_REASON_* result. The call now passes timeout_seconds plus a
60 s margin as the per-call transport timeout, so LND's mapped failure
always arrives first while the transport stays bounded for the actual
hang case (and a user-supplied timeout_seconds scales the bound with
it).

paymentLookup (/v2/router/track) deliberately keeps the 10-minute
default: it holds a browser-facing response open while tracking, and
payments in flight longer than that are delivered by the websocket
subscription path instead. Documented at the call site.

Verified on the fixture: a routed payment with an explicit
timeout_seconds succeeds; an unroutable payment returns LND's mapped
failure reason (Insufficient Balance - no ECONNABORTED, no transport
timeout message); paymentLookup returns the final state of a settled
payment. Both API suites re-pass.
2026-07-19 22:01:23 -07:00
saubyk
19aa332d61 Address review: protect the fire-and-forget channel close from the timeout
LND's DELETE /v1/channels/{channelPoint} streams until the closing tx
confirms, routinely longer than the wrapper's 10-minute bound. The
close call in closeChannel is fire-and-forget (202 returned
immediately, no .catch), so the timeout rejection would have become an
unhandled promise rejection and crashed the process ~10 minutes after
any close that had not yet confirmed. request-promise returned
Bluebird promises whose unhandled rejections only warned, which is why
this never crashed before.

The close now uses a copy of the options with timeout: 0 (same
treatment as the invoice/payment subscriptions) and a .catch that logs
through handleError - errors were never surfaced to the HTTP response
anyway, but logging beats Bluebird's silent warning. This was the only
call site without a rejection handler.

Verified on the fixture: opened a disposable 200k alice->bob channel
via RTL, closed it (202, gone from open and listed in closed after
mining), then requested a close for a bogus channel point - LND
rejects the stream, the catch logs the error (no auth headers in it),
and the process stays up. Read suite re-passes.
2026-07-19 22:01:23 -07:00
saubyk
5d1f55a9c6 Address review: exempt LND subscription streams from the request timeout
The 10-minute timeout added for review feedback would have aborted
LND's long-poll subscription streams (/v2/invoices/subscribe and
/v2/router/track), which legitimately stay open until an invoice
settles or a payment resolves - breaking real-time notifications for
any invoice paid more than 10 minutes after creation.

The wrapper now honors a per-call options.timeout (0 disables the
bound, axios semantics; the 10-minute default still applies everywhere
else), and both subscription calls pass timeout: 0. They also copy the
options object instead of mutating it: addInvoice hands the
session-cached options to subscribeToInvoice, so setting the timeout
in place would have leaked an unbounded timeout to every subsequent
request for that node (getOptions resets form/body/qs but not
timeout).

Verified on the regtest fixture: with a websocket client connected as
alice's frontend, creating an invoice opens the subscription stream,
it survives idle, and paying it from the CLN node delivers the SETTLED
event over the websocket in real time. The per-call override was also
verified directly (timeout: 1000 aborts a slow upstream with
ECONNABORTED; timeout: 0 waits it out). Both API suites (31 read + 12
write checks) re-pass.
2026-07-19 22:01:23 -07:00
saubyk
1efaa24d72 Address review: CSV-encode array form fields and bound request timeout
Review feedback on #1638 flagged two issues in the wrapper:

1. Array form values (eclair's ignoreNodeIds on findroutebetweennodes)
   encoded as "ignoreNodeIds=a,b" via String(), and worse, an empty
   array produced "ignoreNodeIds=" which Eclair's pubkey list parser
   rejects - breaking the default findroute path that worked under
   request-promise (qs omitted empty arrays). Arrays are now omitted
   when empty and comma-joined when not, matching Eclair's CsvSeq list
   format. Verified against the fixture: route eclair->bob->carol is
   found with an empty ignore list and disappears when bob is ignored.
   Under request-promise's qs indexed encoding (ignoreNodeIds[0]=...)
   Eclair never matched the field name, so the ignore list was silently
   dropped; this change makes it effective for the first time.

2. The shared transport had no request timeout, so a hung upstream
   held connections open indefinitely. Added a 10-minute bound,
   sized to the slowest legitimate operations (LND's /v2/router/send
   streams up to timeout_seconds=600; slow CLN channel operations get
   req.setTimeout(600000) upstream).

Both API suites (31 read + 12 write checks) re-pass on the fixture.
2026-07-19 22:01:23 -07:00
saubyk
9a2c702b4c Fill in PR number in release note (#1638) 2026-07-19 22:01:23 -07:00
saubyk
a8baba12bb Replace deprecated request/request-promise with axios
request has been deprecated since 2020 with an unfixed SSRF advisory and
pins vulnerable copies of form-data (critical), qs, tough-cookie and
uuid - 8 of the 13 remaining production audit findings, none fixable by
version bumps (issue #1634, item 1).

All 36 backend files that imported request-promise now use a small
compatibility wrapper (server/utils/request.ts) backed by axios, which
is already a production dependency. The wrapper accepts the existing
options shape (qs, form - object or pre-encoded string, body,
baseUrl/uri, rejectUnauthorized, json), resolves with the response body
directly, and rejects with a plain object mirroring request-promise's
StatusCodeError/RequestError shape, so CommonService.handleError works
unchanged (ECONNREFUSED -> 503, Eclair StatusCodeError -> 500, nested
error body extraction). Auth headers are excluded from rejected errors
so they cannot leak into logs. Callers without json: true (block
explorer, currency rates) still get raw text bodies, and LND's
line-delimited /v2/router/send stream still surfaces as a string for
the existing parser.

Only behavioral code change: CLN verifyMessage used request-promise's
callback style and was ported to the same promise style as signMessage;
four Eclair handlers gained explicit returns to satisfy
noImplicitReturns once the import became typed.

Production npm audit drops from 13 findings (2 critical) to 6 low, all
in the crypto-browserify/elliptic chain tracked in #1634.

Verified against the docker regtest fixture with 43 API checks across
LND, Core Lightning and Eclair: reads, invoice creation, a routed LND
payment over the streaming endpoint, cross-implementation payments from
CLN and Eclair, message sign/verify, channel backup to disk, and
bad-invoice/node-unreachable error mapping. Lint and both production
builds are clean.
2026-07-19 22:01:23 -07:00
saubyk
6e48241d85 Migrate sat_per_byte to sat_per_vbyte for LND requests
Per LND v0.21.0 release notes, the sat_per_byte option will be removed
in v0.22 across CloseChannel, OpenChannel, SendCoins, SendMany, and
walletrpc.BumpFee. LND already treats sat_per_byte as sat/vbyte
internally, so this is a pure rename with no value conversion. Updates
both the wire-format strings sent to LND and the matching TypeScript
identifiers across the close-channel, open-channel, send-coins, and
bump-fee paths.
2026-07-19 22:01:23 -07:00
saubyk
890db72ab6 Restore the Enhancements section header in the release notes 2026-07-19 22:01:23 -07:00
saubyk
95f064ae31 Fill in PR number in release note (#1637) 2026-07-19 22:01:23 -07:00
saubyk
bb2a228662 Realign the Reports Scroll Range select with the date picker
The a11y fix in #1609 wrapped the bare Scroll Range mat-select in a
mat-form-field for its label, but the wrapper reserved subscript space
(78.8px vs the date field 56px) and anchored to the row top, leaving
the date picker ~11px lower on every implementation reports screen.
Use subscriptSizing="dynamic" (no hints are used) and center on the
cross axis, restoring the aligned 56px row from v0.15.8 while keeping
the label. Verified headlessly against the regtest fixture: both
fields now render at identical top/height.

Fixes #1635
2026-07-19 22:01:23 -07:00
saubyk
b47e32c88c Bump pdfmake to 0.3.11 to fix its SSRF advisory
The fix is within the pinned 0.3.x line but the exact pin kept npm
update from reaching it. Clears the last high-severity production
vulnerability; frontend build and full spec suite verified.
2026-07-19 22:01:23 -07:00
saubyk
f75ec24844 Fill in PR number in release note (#1633) 2026-07-19 22:01:23 -07:00
saubyk
09494dcfc0 Update project dependencies to resolve Dependabot security alerts
Apply the bumps from all 20 open Dependabot security PRs (#1583-#1617)
in one pass on the release branch: axios 1.16.0, ws 8.21.0, the
socket.io server stack, express path-to-regexp, follow-redirects,
lodash and the remaining flagged transitive deps. Angular framework
packages move in lockstep to 20.3.26 and the CLI/build toolchain to
20.3.32, which drops the vulnerable node-forge from the tree entirely.
Also pick up in-range fixes without open PRs (qs, uuid, tough-cookie,
cookie, ajv, bn.js, elliptic, socket.io-parser).

npm audit: 85 vulnerabilities (23 prod) -> 30 (14 prod). The remainder
(request/request-promise, csurf, pdfmake, crypto-browserify chain)
needs code changes, not bumps, and is tracked separately.

Verified: lint, 199 frontend specs, backend + frontend production
builds, and an end-to-end smoke test against the docker regtest
fixture (LND, CLN and Eclair auth/getinfo/channels + WS upgrade).
2026-07-19 22:01:23 -07:00
saubyk
da74a84e7e Fill in PR number in release note (#1636) 2026-07-19 22:01:23 -07:00
saubyk
c1a46b1cbd Document the Dependabot dependency-update process in CONTRIBUTING.md
Dependabot PRs target master and are resolved in batch PRs against the
release branch, but the process was undocumented. Add a "Handling
Dependabot PRs" section covering target collection, pinned in-range
fixes, Angular lockstep, from-scratch lockfile regeneration, artifact
rebuild, verification, and issue-tracking for deprecated packages.
2026-07-19 22:01:23 -07:00
saubyk
9a799ea314 Add a topology diagram to the docker fixture README
Mermaid flowchart (rendered by GitHub) showing the channel graph, each
node's chain-backend link -- including eclair's dedicated wallet and
hashblock ZMQ endpoint -- and the protocol RTL uses to reach each node.
2026-07-19 22:01:23 -07:00
saubyk
54359f2ceb Fill in PR number in release note (#1632) 2026-07-19 22:01:23 -07:00
saubyk
6e55059fe2 Add an Eclair node to the regtest docker fixture
Completes backend coverage of RTL's three implementations in the docker/
dev fixture: an eclair node (polarlightning/eclair 0.13.1) joins the three
LND nodes and the CLN node, wired to RTL over its HTTP API with basic auth,
and the seed opens an eclair->bob channel (3.5M sats, 1M pushed), sends two
direct payments and leaves one open invoice.

Non-obvious plumbing this needed:

- polarlightning/eclair instead of acinq/eclair: the official image is
  amd64-only (useless on Apple Silicon) and its newest versioned tag is
  years stale; Polar builds the same ACINQ source multi-arch.
- Eclair has no on-chain wallet of its own -- it drives a bitcoind wallet.
  A new eclair-wallet-init container creates a dedicated "eclair" wallet
  before the node starts; without it eclair attaches to "the default
  loaded wallet", i.e. the rtldev mining wallet.
- bitcoind now also publishes a zmqpubhashblock endpoint (28336): eclair's
  bitcoind.zmqblock consumes the hashblock topic, not the rawblock one LND
  uses. Wired to rawblock, eclair never sees new blocks and channels hang
  in WAIT_FOR_FUNDING_CONFIRMED.
- Eclair confirms channels at 8 blocks (channel.min-depth-blocks), not 6,
  and 'open' returns before the funding tx is broadcast -- the seed waits
  for the mempool and mines 8 blocks for this channel.

Adds a bin/e-cli helper (eclair-cli with the API password), updates the
README, and verified end-to-end: seed completes, the channel reaches
NORMAL, both payments settle, and RTL's /rtl/api/ecl endpoints return the
node, channel and invoice data.
2026-07-19 22:01:23 -07:00
saubyk
c2b8670099 Rebuild compiled cln/channels.js to match its source (#1606)
The #1606 fix added 'channel.connected = !!channel.peer_connected' to
server/controllers/cln/channels.ts but the committed compiled artifact
backend/controllers/cln/channels.js was never regenerated, leaving it stale.
Rebuild it so the committed backend output matches its TypeScript source.
2026-07-19 22:01:23 -07:00
saubyk
e0fce065d5 Address 2nd review: guard limiter callbacks, CLN postPeer aliases, one-shot done
Follow-up to the second #1629 review:

- F4: the limiter invokes its done callback outside the surrounding .then/.catch,
  so a throw in the response-send body became an unhandled rejection with no
  response (a 500 -> hang regression, notably on LND postPeer where the inner
  .catch was removed). Wrap each converted done body in try/catch that sends the
  error response, guarded by res.headersSent.
- F5: CLN postPeer re-listed peers but never resolved their aliases, so a freshly
  connected CLN peer came back with a raw node id (the frontend uses this response
  directly). Resolve aliases through the same bounded limiter, matching LND postPeer.
- F6: make runWithConcurrencyLimit fire 'done' exactly once via a one-shot guard,
  so multiple synchronous completions (e.g. non-function task elements) can't
  double-send the response.
2026-07-19 22:01:23 -07:00
saubyk
e11899a051 Broaden release note to cover LND peers parity and getAlias hardening (#1629) 2026-07-19 22:01:23 -07:00
saubyk
bd74132265 Address review: self-contained CLN getAlias, LND peers bound, limiter guard
Follow-up to the #1501 review (PR #1629):

- F1: CLN getAlias now builds its request from selNode.authentication.options
  instead of the shared module-level 'options'. That coupling meant a cold
  Peers/route lookup dereferenced a null 'options'; with the new limiter
  swallowing per-task throws, that returned 200 with every alias unset. Aliases
  now resolve regardless of call order, with a truncated-id fallback if auth
  options are somehow absent.
- F2: mirror the 20-way concurrency bound to LND peers (getPeers and postPeer),
  which had the same unbounded Promise.all alias fan-out. Eclair resolves
  aliases inline from a bulk nodes list, so it needs no change.
- F3: normalize runWithConcurrencyLimit's start count to at least 1 so a
  non-positive limit can't leave 'done' unfired and hang the response.
2026-07-19 22:01:23 -07:00
saubyk
a09eb7d4c5 Fill in PR number in release note (#1629) 2026-07-19 22:01:23 -07:00
saubyk
fbd336a89b Bound CLN alias resolution on peers and route lookups (#1501)
RTL resolves peer aliases by calling listnodes once per peer. A prior fix
(1cec7b1) bounded this to 20 concurrent calls plus a cache for the channel
list, but the peers list and route lookup still used an unbounded Promise.all,
firing one request per peer at once. On nodes with many peers this overwhelms
clnrest and fails with 'Resource temporarily unavailable (os error 11)'
(EAGAIN), so aliases fall back to raw node IDs.

- peers.ts and network.ts getRoute now resolve aliases via
  runWithConcurrencyLimit(tasks, 20, ...), matching the channel list.
- Harden runWithConcurrencyLimit to call done() immediately for an empty task
  list; otherwise an empty peers/route set would never send a response.
- Give the alias cache a 6h TTL and a max size (evicting oldest) so aliases
  refresh without an RTL restart and the cache can't grow unbounded.
2026-07-19 22:01:23 -07:00
saubyk
75dba90fae Add 0.15.9 release note for the accessibility form-label fix (#1609) 2026-07-19 22:01:23 -07:00
saubyk
f93bc7b1d8 Restore trailing newlines stripped from six modal templates
The accessibility edits dropped the final newline from six form templates.
Add it back so these files end with a newline again (POSIX text-file
convention; keeps diffs clean and avoids no-newline lint noise).
2026-07-19 22:01:23 -07:00
SIDHARTH20K4
86d10df369 fix: capitalize Info Type label for consistency 2026-07-19 22:01:23 -07:00
SIDHARTH20K4
eed6b8aca2 fix: address review feedback - remove remaining positive tabindex values and add CLN parity fixes 2026-07-19 22:01:23 -07:00
SIDHARTH20K4
522b7e307d fix: address review feedback - fix invalid button types and remove fxFlex from mat-select 2026-07-19 22:01:23 -07:00
SIDHARTH20K4
5690367d07 fix: add missing mat-label to form fields and remove positive tabindex values 2026-07-19 22:01:23 -07:00
saubyk
46c710ca7a Backfill 0.15.9 release notes for pre-process merged PRs
Several PRs were merged onto Release-0.15.9 before the per-PR release-notes
process was established, leaving them undocumented. Add entries for:

- #1581 Fix page-load error when a channel alias is undefined (Bug Fixes)
- #1601 Fix stale auth options blocking a not-yet-ready node (Bug Fixes)
- #1582 Add Disable Authentication option (Enhancements)
- #1621 Rebuild the regtest docker fixture (Developer Tooling)
2026-07-19 22:01:23 -07:00
saubyk
1782ae2b20 Fill in PR number in release note (#1627) 2026-07-19 22:01:23 -07:00
saubyk
343338cd4b Show Blocks till Maturity by default on LND Pending Force Closing list (#1567)
Blocks-till-maturity is critical information for a force-closing channel but
was only visible in the per-channel detail modal. The column and its data
binding already existed in the pending force-closing table (and was selectable
via column settings); it was just missing from the default column selection.
Add blocks_til_maturity to the pending_force_closing default columnSelection
and columnSelectionSM so it is surfaced on the list by default on both desktop
and mobile.
2026-07-19 22:01:23 -07:00
saubyk
30712392e6 Fill in PR number in release note (#1626) 2026-07-19 22:01:23 -07:00
saubyk
2a9aee2597 Restore items-per-page dropdown on paginated tables (#1580)
A dependency-update commit in the 0.15.8-beta cycle mechanically renamed
the paginator binding [showFirstLastButtons] to [hidePageSize] on every
mat-paginator while keeping the same 'screenSize === XS ? false : true'
expression. The two properties have opposite polarity, so this inverted
the behavior: on desktop the page-size selector was hidden (locking users
to 10 items per page) and the first/last-page buttons were dropped as
collateral. Revert the ~44 affected paginators back to [showFirstLastButtons]
across the LND, CLN, Eclair and shared tables.
2026-07-19 22:01:23 -07:00
saubyk
f518488ecb Clarify connected-mirror comment and pin the fixture rune path
Address review F7/F8 on #1625:

F7 (verification): the onchain.ts `connected === false` branch reads /v1/listfunds
(CLN's own connected field) and only buckets balance as inactive — it is not the
listPeerChannels mirror and does no close logic, so the coercion activates nothing
there. Reword the mirror comment, which inaccurately implied onchain.ts consumes it;
the mirror simply keeps the documented backward-compat `connected` field defined.

F8: hardcode the rune path in create-rune.sh to /root/.lightning/rtl.rune so it
matches the volume mount, healthcheck and RTL runePath instead of deriving it from
${LIGHTNINGD_DATA}, removing the silent-divergence risk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
cbf7a14c95 Guard the LND channel information modal explorer link (#1606 parity)
Address review F6 on #1625: the LND channel information modal has the same
unguarded selNode.settings.blockExplorerUrl binding as the CLN one, and it is
opened without selNode from the active-HTLCs and channel-backup tables, so it can
blank out the same way. Guard the explorer link (*ngIf + a no-op click when the
url is absent) so a missing selNode can no longer blank the dialog. Eclair's modal
doesn't use selNode.settings, so it needs no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
9c3cd983b4 Make the CLN dev-fixture rune creation self-healing
Address review F5 on #1625: create-rune.sh was a one-shot poststart script — if
the RPC wasn't ready within its poll or createrune failed, it exited without ever
writing rtl.rune, and since the cln healthcheck gates on that file and rtl waits
on service_healthy, a failed pass deadlocked the whole stack until 'down -v'.

Drive rune creation from the healthcheck instead: the script is now a quick,
idempotent single attempt, and the healthcheck runs it on every interval, so a
transient RPC-startup race just retries and self-heals. Moved the script out of
lightning-poststart.d to /opt and updated the healthcheck, compose comment and
README accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
99cb60d2a7 Type selNode as the RTL Node model in the pending table
Address review F4 on #1625: the new selNode field resolved to the global DOM
Node type because the RTL Node model was not imported. Import Node from
shared/models/RTLconfig so the field, the rootSelectedNode store value, and the
CLNChannelInformationComponent it feeds all agree, restoring type-checking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
c15fa04986 Fix CLN channel View Info modal blanking for disconnected channels (#1606)
The channel information modal renders a block-explorer link from
selNode.settings.blockExplorerUrl, but the pending/inactive channels table
opened the modal without passing selNode. With it undefined, that binding threw
during change detection and blanked every field below it — State, Connected,
Private and the balances all rendered without a value. A disconnected channel
moves to the pending/inactive table, so this is what surfaced on View Info for a
disconnected channel (the symptom in the original report).

Pass selNode from the pending table (matching the open table), and guard the
modal's explorer link (*ngIf + a no-op click when the url is absent) so a missing
selNode can no longer blank the whole dialog. Add a regression test asserting the
pending table passes selNode when opening the modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
b790dc7abf Add a Core Lightning node to the regtest docker fixture
The fixture had only bitcoind + three LND nodes, so RTL's Core Lightning screens
had no backend to exercise. Add a `cln` node (official elementsproject/lightningd
image, multi-arch) wired to RTL over clnrest with rune auth, and have the seed
open a cln->alice channel so the CLN channel/peer screens have real data.

- docker-compose.yml: cln service (clnrest on 0.0.0.0:3010, https), a healthcheck
  gated on the rune file so rtl waits for it, and rtl now mounts the cln volume
  read-only and depends on cln being healthy. The rtl image is parameterized via
  ${RTL_IMAGE:-...} so an unreleased build can be tested against the fixture.
- cln/poststart.d/create-rune.sh: once the RPC is up, create a master rune and
  write it as LIGHTNING_RUNE="..." where RTL reads it (runePath). Polls for RPC
  readiness because the image entrypoint can invoke poststart before the socket
  exists.
- RTL-Config.regtest.json: add node index 4 (CLN, rune auth, https://cln:3010).
- seed.sh: fund cln, connect to alice, open a 4,000,000 sat channel, wait active.
- README + release notes updated.

Used to verify the CLN channel connection-status fix (#1606) end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
80af65d7fe Read peer_connected in pending-table close-channel guard
Address review F3 on #1625: the Close-Channel *ngIf still read the legacy
`connected` field while its neighboring column now reads peer_connected. Point
it at peer_connected directly so it no longer depends on the backend mirror,
removing the latent coupling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
512aea96e5 Coerce mirrored peer_connected to a boolean
Address review feedback on #1625: copy peer_connected onto the legacy
`connected` field as a real boolean (!!), so strict-equality readers such as
onchain.ts's `connected === false` behave correctly when peer_connected is
absent, instead of leaving `connected` undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
5659ba250b Add release notes doc for 0.15.9
Introduce a release-notes/ folder to collect the changes for each release, and
add Release-notes-0.15.9.md with the entry for the CLN channel connection status
fix (#1606 / #1625). New process: every PR for a release appends its entry to the
respective release notes document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
saubyk
16d48417d8 Fix CLN channel connection status shown inconsistently (#1606)
CLN's listpeerchannels reports connection state as peer_connected, but the
open/pending channel list columns read the legacy `connected` field, which the
backend never populated. It was therefore always empty, so the list always
rendered "Disconnected" while the detail panel (which reads peer_connected)
showed the true state — the contradiction reported in #1606.

Normalize `connected = peer_connected` in the backend listPeerChannels response
so legacy consumers stay in sync, and point the list columns at peer_connected
directly. Add regression specs asserting the connected column follows
peer_connected even when the legacy field disagrees.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:01:23 -07:00
Matt Hill
42cb3c76e8 Fix setOptions cache poisoning when one node's auth load fails 2026-07-19 22:01:23 -07:00
ShahanaFarooqui
266167cf53 Update documentation for Disable Authentication 2026-07-19 22:01:23 -07:00
ShahanaFarooqui
0057bee53d Add Disable Authentication option in the frontend 2026-07-19 22:01:23 -07:00
ShahanaFarooqui
4613b889bf Add Disable Authentication option in the backend 2026-07-19 22:01:23 -07:00
ShahanaFarooqui
7d32bd68e9 Fix error on page load if channels alias is undefined 2026-07-19 22:01:23 -07:00
ShahanaFarooqui
7d676dc940 Update version 0.15.9 2026-07-19 22:01:23 -07:00
Suheb
3ae6d65ca2
Update rtlreviewbot.yml
Updated the timeout limit to 20 minutes
2026-07-19 13:35:34 -07:00
saubyk
1a6cb0b0a8 Source .env in seed.sh so it prints the real password
Docker Compose reads .env automatically; bash does not. seed.sh referenced
${RTL_PASSWORD:-password} without sourcing it, so the default always won and
the summary told the user to log in with 'password' -- the blacklisted value
this branch just removed.
2026-07-16 00:15:20 -07:00
saubyk
df48de1140 Fix the regtest fixture using a password RTL blacklists
The fixture added in #1621 set multiPass to "password". RTL blacklists weak
passwords (PASSWORD_BLACKLIST in consts-enums-functions.ts: 'password',
'changeme', 'moneyprintergobrrr') and forces a change when one is used:
login.component.ts sets defaultPassword: true, and app.component.ts then
redirects to /rtl/settings/auth.

So every login with the documented password was bounced to the change
password screen and never reached the dashboard. The check is session based
rather than first run only, so this happened on every fresh browser session,
not just the first.

Password is now 'rtldev'. .env documents why it must stay off the blacklist.
2026-07-16 00:02:00 -07:00
saubyk
6a01e96c98 Remove the unused bitcoind and lnd build contexts
Nothing references these now that the fixture pulls Polar's multi-arch
images instead of building locally. They could not produce a working image
anyway: the bitcoind Dockerfile is ubuntu:18.04 (EOL 2023) installing from
a PPA, and the lnd one builds from golang:1.11-alpine at a 2018 commit.

Leaving them would repeat the mistake that made the old setup misleading,
where plausible-looking files pointed at something that no longer worked.
2026-07-15 23:26:30 -07:00
saubyk
4e2b7ca978 Rebuild the regtest dev fixture: bitcoind + 3 LND nodes + RTL
The setup in docker/ has been unable to start since Feb 2021. The boltz
service added in f817ae39 references BOLTZ_* variables that are not in .env
(so its ports render as ":" and compose rejects the file with "invalid
proto"), a ./boltz build context that has never existed in the repo, and
boltz_data/boltz_shared volumes that are never declared. Because compose
validates the whole project up front, this broke every service: "docker
compose up -d bitcoind", the first command in the README, failed too.

Rather than repair a five-year-old file pinned to bitcoind 0.19.0 and lnd
0.12.0-beta, this replaces it.

What changed:

- bitcoind 30.0 and 3x lnd 0.20.0-beta, using Polar's images. They are
  multi-arch, so nothing is built locally and this works on arm64. The old
  setup built bitcoind and lnd from local Dockerfiles.
- Three nodes, not one: alice -> bob -> carol. bob forwards, so RTL's
  routing and forwarding screens have data. Two nodes leave them empty.
- scripts/seed.sh funds the nodes, opens channels, and makes payments with
  fixed amounts. A fresh run reproduces identical state, so screenshots
  taken before and after a change differ only by the change. It is
  deliberately not idempotent and refuses to run against a seeded network,
  since re-running would double-fund it.
- rtl/RTL-Config.regtest.json configures all three nodes. RTL rewrites its
  config on startup, so an init container stages a copy into a volume: a
  read-only bind mount makes RTL exit with EROFS, and a writable one would
  let RTL modify a tracked file. It is not named RTL-Config.json because
  .gitignore matches that bare name at any depth.
- bin/ln-cli now takes a node name and passes --lnddir=/home/lnd/.lnd,
  because 'docker compose exec' lands as root while lnd's datadir is under
  /home/lnd. Both helpers use compose v2.
- README rewritten to match.

Boltz, Core Lightning and Eclair are left out of this pass. Polar publishes
multi-arch clightning and eclair images, so adding them later needs compose
services, config entries and seed adapters, but no image building.

Verified from a clean 'down -v': all nodes sync, channels go active, 5/5
payments route through bob, bob records 5 forwards, and RTL serves the UI
with all three nodes configured. Two independent from-scratch runs produced
identical balances.

The old bitcoind/ and lnd/ build contexts are now unreferenced but left in
place for a follow-up.
2026-07-15 23:26:30 -07:00
Suheb
da25d16fb4
Update rtlreviewbot.yml
Updated the shim to point to rtlreviewbot-action
2026-06-09 19:51:51 -07:00
saubyk
715afa9043
Update copyright year in LICENSE to 2018-2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:16:26 -07:00
Suheb
f2314fcacf
Update rtlreviewbot.yml
Added the app installation id
2026-05-01 20:14:54 -07:00
Suheb
69888dd7b8
Create rtlreviewbot.yml
Added the Yaml file for the code review bot
2026-05-01 20:10:35 -07:00
ShahanaFarooqui
d8e6bb1b68 Fix Missing Memo in LND Invoices Table 2026-02-09 18:13:13 -08:00
ShahanaFarooqui
e8d5685948 Decode Payment with get/post method not fetch
Fixes #1550
2026-02-09 18:13:13 -08:00
ShahanaFarooqui
d038e15bd7 Version Update 2026-02-09 18:13:13 -08:00
ShahanaFarooqui
bdf9c832dd Side Navigation collapse bug fix 2026-02-01 09:06:07 -08:00
ShahanaFarooqui
4e08fd7cba Update project code according to dependencies update 2026-02-01 09:06:07 -08:00
ShahanaFarooqui
2f6cdb8fe0 Update project dependencies 2026-02-01 09:06:07 -08:00
ShahanaFarooqui
f3f21c5949 Extract memo for keysend 2026-02-01 09:06:07 -08:00
ShahanaFarooqui
08326f1383 fix channel backup error 2026-02-01 09:06:07 -08:00
ShahanaFarooqui
e9e33da14d Fix page load bug due to zero connected channels (#1529) 2026-02-01 09:06:07 -08:00
3nprob
bbeb2c3b17 update .dockerignore (#1514)
* add src/app/shared/test-helpers to .dockerignore

* chore: add Dockerfile to .dockerignore
2026-02-01 09:06:07 -08:00
3nprob
10fc3368e9 Upgrade nodejs to v22 (#1515) 2026-02-01 09:06:07 -08:00
ShahanaFarooqui
d49124c052 Update Version 2026-02-01 09:06:07 -08:00
Suheb
0c90e454e8
Update export_traffic.py
Updated to the github repo name
2025-09-20 19:08:48 -07:00
Suheb
393262fa32
Update traffic-exporter.yml
updated to the correct credentials filename
2025-09-20 19:03:07 -07:00
Suheb
b67867af44
Update traffic-exporter.yml
updated the script path for real this time :)
2025-09-20 18:52:09 -07:00
Suheb
3f4236b4f2
Update traffic-exporter.yml
updated to the correct script path
2025-09-20 18:50:19 -07:00
Suheb
c0c8d77691
Update traffic-exporter.yml
added base64 decoding for the google sheets credentials
2025-09-20 18:46:15 -07:00
Suheb
84d92ff6f6
Update traffic-exporter.yml
Updated the path of python script to .github/scripts
2025-09-20 18:22:54 -07:00
Suheb
f1ed21915a
Create export_traffic.py
Github metrics export
2025-09-20 18:21:38 -07:00
Suheb
c27f4aff45
Create traffic-exporter.yml
automated metrics extraction
2025-09-20 17:55:31 -07:00
ShahanaFarooqui
7340cb390a
Release 0.15.6 (#1506)
Fix for Resource temporarily unavailable error for CLN channel alias list
Security fix for npm vulnerabilities
2025-09-09 02:18:23 -07:00
ShahanaFarooqui
847923533e
Github: CI action version updates (#1498) 2025-07-07 13:00:13 -07:00
ShahanaFarooqui
8a0304c162
Release 0.15.5 (#1492)
* Version Updated to 0.15.5-beta

* Fix to show correct experimental-dual-fund configuration from listconfig (#1479)

* feat: boltz swap in refund address (#1490)

require a refund address when creating a swap in and paying it
externally to make sure the swap can be refunded automatically if it
fails.

---------

Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
2025-07-07 11:36:23 -07:00
saubyk
7a03673e6e
added aider to gitignore
Signed-off-by: saubyk <39208279+saubyk@users.noreply.github.com>
2025-04-15 18:36:25 -07:00
Suheb
05fbe7f65b
Create CONTRIBUTING.md
Moving contribution doc to root
2025-04-15 17:41:38 -07:00
502 changed files with 14968 additions and 11243 deletions

View file

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

View file

@ -4,6 +4,7 @@
.github/
.settings/
.vscode/
src/app/shared/test-helpers/
frontend/
backend/
backup/
@ -12,6 +13,7 @@ coverage/
dist/
docker/
dockerfiles/
Dockerfile
logs/
node_modules/
node_modules_old/

View file

@ -24,7 +24,7 @@
],
"rules": {
"@angular-eslint/component-selector": ["error", { "prefix": "rtl", "style": "kebab-case", "type": "element" }],
"@angular-eslint/directive-selector": ["error", { "style": "camelCase", "type": "attribute" }],
"@angular-eslint/directive-selector": ["error", { "style": "camelCase", "type": "attribute", "prefix": [] }],
"@angular-eslint/consistent-component-styles": "off",
"@angular-eslint/prefer-on-push-component-change-detection": "off",
"@angular-eslint/prefer-standalone": "off",
@ -32,6 +32,11 @@
"@angular-eslint/sort-ngmodule-metadata-arrays": "off",
"@angular-eslint/use-component-view-encapsulation": "off",
"@angular-eslint/use-injectable-provided-in": "off",
"@angular-eslint/prefer-inject": "off",
"@angular-eslint/sort-keys-in-type-decorator": "off",
"@angular-eslint/prefer-signals": "off",
"@angular-eslint/prefer-host-metadata-property": "off",
"@angular-eslint/prefer-output-emitter-ref": "off",
"@typescript-eslint/member-delimiter-style": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/type-annotation-spacing": 0,
@ -207,7 +212,8 @@
"@angular-eslint/template/prefer-ngsrc": "off",
"@angular-eslint/template/prefer-control-flow": "off",
"@angular-eslint/template/prefer-self-closing-tags": "off",
"@angular-eslint/template/use-track-by-function": "off"
"@angular-eslint/template/use-track-by-function": "off",
"@angular-eslint/template/prefer-template-literal": "off"
}
}
]

11
.github/README.md vendored
View file

@ -4,7 +4,7 @@
<a href="https://snyk.io/test/github/Ride-The-Lightning/RTL"><img src="https://snyk.io/test/github/Ride-The-Lightning/RTL/badge.svg" alt="Known Vulnerabilities" data-canonical-src="https://snyk.io/test/github/Ride-The-Lightning/RTL" style="max-width:100%;"></a>
[![license](https://img.shields.io/github/license/DAVFoundation/captain-n3m0.svg?style=flat-square)](https://github.com/DAVFoundation/captain-n3m0/blob/master/LICENSE)
**Intro** -- [Application Features](./docs/Application_features.md) -- [Road Map](./docs/Roadmap.md) -- [Application Configurations](./docs/Application_configurations.md) -- [Core Lightning](./docs/Core_lightning_setup.md) -- [Eclair](./docs/Eclair_setup.md) -- [Contribution](./docs/Contributing.md)
**Intro** -- [Application Features](./docs/Application_features.md) -- [Road Map](./docs/Roadmap.md) -- [Application Configurations](./docs/Application_configurations.md) -- [Core Lightning](./docs/Core_lightning_setup.md) -- [Eclair](./docs/Eclair_setup.md) -- [Contribution](./docs/Contributing.md) -- [Release Notes](../release-notes)
* [Introduction](#intro)
* [Architecture](#arch)
@ -55,7 +55,7 @@ To download from master (*not recommended*):
```
$ git clone https://github.com/Ride-The-Lightning/RTL.git
$ cd RTL
$ npm install --omit=dev --legacy-peer-deps
$ npm ci --omit=dev --legacy-peer-deps
```
#### Or: Update existing dependencies
```
@ -63,12 +63,9 @@ $ cd RTL
$ git reset --hard HEAD
$ git clean -f -d
$ git pull
$ npm install --omit=dev --legacy-peer-deps
$ npm ci --omit=dev --legacy-peer-deps
```
#### Error on npm install
If there is an error with `upstream dependency conflict` message then replace `npm install --omit=dev` with `npm install --omit=dev --legacy-peer-deps`.
### <a name="prep"></a>Prep for Execution
RTL requires its own config file `RTL-Config.json`, to start the server and provide user authentication on the app.
@ -127,6 +124,8 @@ For details on all the configuration options refer to [this page](./docs/Applica
RTL requires the user to be authenticated by the application first, before allowing access to LND functions.
Specific password must be provided in RTL-Config.json (in plain text) for authentication. Password should be set with `multiPass:<user defined>` in the `Authentication` section of RTL-Config.json. Default initial password is `password`.
For hosted solutions such as BTCPayServer, we implemented an "SSO" setup using a one-time-use cookie. For other vendors which have their own authentication service, we introduced a "disableAuth" option, which disables authentication at the RTL level. When using this option, the authentication security is the responsibility of the Vendor. This option is NOT recommended for standalone users of RTL.
### <a name="start"></a>Start the Server
Run the following command:

View file

@ -5,7 +5,8 @@ parameters have `default` values for initial setup and can be updated after RTL
### RTL-Config.json<br />
```
{
"multiPass": "<The password in plain text, default 'password', Required>",
"multiPass": "<The password in plain text, default 'password', Required if disableAuth is false>",
"disableAuth": "<The flag to disable application authentication, default 'false', Optional>",
"port": "<port number for the rtl node server, default '3000', Required>",
"host": "<host for the rtl node server, default 'all IPs', Optional>",
"defaultNodeIndex": <Default index to load when rtl server starts, default 1, Optional>,
@ -55,7 +56,8 @@ If the environment variables are set, it will take precedence over the parameter
PORT (port number for the rtl node server, default 3000, Optional)<br />
HOST (host for the rtl node server, default localhost, Optional)<br />
DB_DIRECTORY_PATH (Path for the folder where rtl database file should be saved, default RTL root directory, Optional)
APP_PASSWORD (Plaintext password to be provided by the parent container, NOT suggested for standalone RTL applications, to be used by Umbrel) (Optional)<br />
APP_PASSWORD (Plaintext password to be provided by the parent container, NOT suggested for standalone RTL applications, only to be used by Vendors providing their own authentication service) (Optional)<br />
DISABLE_AUTH (Flag to disable authentication, NOT recommended for standalone RTL applications, only to be used by Vendors providing their own authentication service) (Optional)<br /
LN_IMPLEMENTATION (LND/CLN/ECL. Default 'LND', Optional)<br />
LN_SERVER_URL (LN server URL for LNP REST APIs, default https://127.0.0.1:8080) (Optional)<br />
SWAP_SERVER_URL (Swap server URL for REST APIs, default http://127.0.0.1:8081) (Optional)<br />

View file

@ -33,7 +33,7 @@ To download from master (*not recommended*):
```
$ git clone https://github.com/Ride-The-Lightning/RTL.git
$ cd RTL
$ npm install --omit=dev --legacy-peer-deps
$ npm ci --omit=dev --legacy-peer-deps
```
#### Or: Update existing build
@ -42,12 +42,9 @@ $ cd RTL
$ git reset --hard HEAD
$ git clean -f -d
$ git pull
$ npm install --omit=dev --legacy-peer-deps
$ npm ci --omit=dev --legacy-peer-deps
```
#### Error on npm install
If there is an error with `upstream dependency conflict` message then replace `npm install --omit=dev` with `npm install --omit=dev --legacy-peer-deps`.
### <a name="prep"></a>Prep for Execution
RTL requires its own config file `RTL-Config.json`, to start the server and provide user authentication on the app
* Rename the file `Sample-RTL-Config.json` to `RTL-Config.json` located at`./RTL`

View file

@ -28,7 +28,7 @@ To download from master (*not recommended*) follow the below instructions:
```
$ git clone https://github.com/Ride-The-Lightning/RTL.git
$ cd RTL
$ npm install --omit=dev --legacy-peer-deps
$ npm ci --omit=dev --legacy-peer-deps
```
#### Or: Update existing build
```
@ -36,12 +36,9 @@ $ cd RTL
$ git reset --hard HEAD
$ git clean -f -d
$ git pull
$ npm install --omit=dev --legacy-peer-deps
$ npm ci --omit=dev --legacy-peer-deps
```
#### Error on npm install
If there is an error with `upstream dependency conflict` message then replace `npm install --omit=dev` with `npm install --omit=dev --legacy-peer-deps`.
### <a name="prep"></a>Prep for Execution
RTL requires its own config file `RTL-Config.json`, to start the server and provide user authentication on the app.
* Rename the file `Sample-RTL-Config.json` to `RTL-Config.json` located at`./RTL`..

86
.github/scripts/export_traffic.py vendored Normal file
View file

@ -0,0 +1,86 @@
import os
import gspread
import requests
from datetime import datetime
from oauth2client.service_account import ServiceAccountCredentials
# --- CONFIGURATION ---
# GitHub repository details
GITHUB_REPO = "Ride-The-Lightning/RTL" # e.g., "google/gemini"
# Get credentials from environment variables (for GitHub Actions)
# For local testing, you can temporarily hardcode these or set them in your terminal
GITHUB_TOKEN = os.getenv('GH_TOKEN')
GOOGLE_SHEETS_CREDENTIALS = os.getenv('GOOGLE_SHEETS_CREDENTIALS')
# --- MAIN SCRIPT ---
def get_github_traffic(repo, token, metric):
"""Fetches view or clone traffic data from the GitHub API."""
url = f"https://api.github.com/repos/{repo}/traffic/{metric}"
headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json"
}
try:
response = requests.get(url, headers=headers)
response.raise_for_status() # Raises an error for bad responses (4xx or 5xx)
data = response.json()
print(f"Successfully fetched {metric} data. Total: {data['count']}, Unique: {data['uniques']}.")
return data
except requests.exceptions.RequestException as e:
print(f"Error fetching GitHub data for {metric}: {e}")
return None
def main():
"""Main function to fetch data and write to Google Sheets."""
# 1. Authenticate with Google Sheets
try:
scope = ["https://spreadsheets.google.com/feeds", 'https://www.googleapis.com/auth/spreadsheets',
"https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/drive"]
# Use credentials from environment variable
creds = ServiceAccountCredentials.from_json_keyfile_name(GOOGLE_SHEETS_CREDENTIALS, scope)
client = gspread.authorize(creds)
# Open the worksheet
# Make sure your service account email has editor access to this sheet
sheet = client.open("GitHub Repo Traffic").sheet1
print("Successfully connected to Google Sheets.")
except Exception as e:
print(f"Error connecting to Google Sheets: {e}")
return
# 2. Fetch traffic data from GitHub
views_data = get_github_traffic(GITHUB_REPO, GITHUB_TOKEN, "views")
clones_data = get_github_traffic(GITHUB_REPO, GITHUB_TOKEN, "clones")
# 3. Prepare and append the data
if views_data and clones_data:
today_str = datetime.now().strftime("%Y-%m-%d")
# Create a new row with today's summary
new_row = [
today_str,
views_data['count'],
views_data['uniques'],
clones_data['count'],
clones_data['uniques']
]
# Check if headers are needed
if not sheet.get_all_values():
sheet.append_row(["Date", "Total Views", "Unique Views", "Total Clones", "Unique Clones"])
sheet.append_row(new_row)
print(f"Successfully appended data for {today_str} to the sheet.")
if __name__ == "__main__":
# For local testing, you need to set up the credentials file path
# For example: os.environ['GOOGLE_SHEETS_CREDENTIALS'] = 'your-key-file.json'
# And your GitHub token: os.environ['GH_TOKEN'] = 'your_github_token'
# Check if credentials are set
if not GITHUB_TOKEN or not GOOGLE_SHEETS_CREDENTIALS:
print("Error: Required environment variables GH_TOKEN or GOOGLE_SHEETS_CREDENTIALS are not set.")
else:
main()

View file

@ -15,21 +15,29 @@ on:
jobs:
prepare:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version:
- '18.x'
- '20.x'
- '22.x'
- '24.x'
steps:
- name: Checkout source code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: ${{ matrix.node-version }}
- name: Cache node_modules
uses: actions/cache@v2
uses: actions/cache@v4
id: cache-npm-packages
with:
path: node_modules
key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
key: ${{ runner.OS }}-${{ matrix.node-version }}-build-${{ hashFiles('**/package-lock.json') }}
- name: Install NPM dependencies
if: steps.cache-npm-packages.outputs.cache-hit != 'true'
@ -39,21 +47,29 @@ jobs:
name: Lint
runs-on: ubuntu-latest
needs: prepare
strategy:
fail-fast: false
matrix:
node-version:
- '18.x'
- '20.x'
- '22.x'
- '24.x'
steps:
- name: Checkout source code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: ${{ matrix.node-version }}
- name: Cache node_modules
uses: actions/cache@v2
uses: actions/cache@v4
id: cache-npm-packages
with:
path: node_modules
key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
key: ${{ runner.OS }}-${{ matrix.node-version }}-build-${{ hashFiles('**/package-lock.json') }}
- name: Install NPM dependencies
if: steps.cache-npm-packages.outputs.cache-hit != 'true'
@ -70,19 +86,19 @@ jobs:
CI: true
steps:
- name: Checkout source code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: ${{ matrix.node-version }}
- name: Cache node_modules
uses: actions/cache@v2
uses: actions/cache@v4
id: cache-npm-packages
with:
path: node_modules
key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
key: ${{ runner.OS }}-${{ matrix.node-version }}-build-${{ hashFiles('**/package-lock.json') }}
- name: Install NPM dependencies
if: steps.cache-npm-packages.outputs.cache-hit != 'true'

View file

@ -21,7 +21,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
- name: Cache node_modules
uses: actions/cache@v4
@ -82,7 +82,7 @@ jobs:
tar -czf /tmp/rtl-build-$VERSION.tar.gz frontend backend rtl.js package.json package-lock.json
zip -r /tmp/rtl-build-$VERSION.zip frontend backend rtl.js package.json package-lock.json
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: rtl-build-${{ github.event.release.tag_name || github.event.inputs.version || '' }}
path: |

31
.github/workflows/rtlreviewbot.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: rtlreviewbot
on:
issue_comment:
types: [created]
pull_request:
types: [review_requested, closed]
permissions:
contents: read
jobs:
review:
if: ${{ github.event_name != 'issue_comment' || github.event.issue.pull_request != null }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: Ride-The-Lightning/rtlreviewbot-action@main
with:
event_name: ${{ github.event_name }}
event_action: ${{ github.event.action }}
repo: ${{ github.repository }}
pr_number: ${{ github.event.issue.number || github.event.pull_request.number }}
actor: ${{ github.event.sender.login }}
comment_body: ${{ github.event.comment.body }}
comment_id: ${{ github.event.comment.id }}
installation_id: 127679607
app_id: ${{ secrets.GATEWAY_APP_ID }}
private_key: ${{ secrets.GATEWAY_PRIVATE_KEY }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

39
.github/workflows/traffic-exporter.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: Export Repo Traffic to Google Sheets
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install gspread oauth2client requests
# --- NEW STEP TO DECODE THE SECRET ---
- name: Write Google credentials to a file
id: write_creds
uses: timheuer/base64-to-file@v1.2
with:
fileName: 'google-credentials.json'
encodedString: ${{ secrets.GOOGLE_SHEETS_CREDENTIALS }}
# --- MODIFIED STEP TO RUN THE SCRIPT ---
- name: Run the Python script
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# Point the environment variable to the file path created in the previous step
GOOGLE_SHEETS_CREDENTIALS: ${{ steps.write_creds.outputs.filePath }}
run: python .github/scripts/export_traffic.py

2
.gitignore vendored
View file

@ -63,3 +63,5 @@ RTL-Config-Regtest.json
RTL-Config-Signet.json
RTL-Config-Testnet.json
RTL-Config-All.json
.aider*
.continue

122
CLAUDE.md Normal file
View file

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

91
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,91 @@
## Contributing to RTL
Thanks for your interest in contributing to the development of RTL. RTL is a community project and aspires to remain free and open source for the benefit of the community. With that objective in mind, this document provides contribution guidelines which the community can utilize to contribute towards the development and maintenance of this software.
### <a name="how"></a>How Can I Contribute
There are multiple ways you can contribute towards the development and not all of those methods involve coding. Below are a few examples on how meaningful contributions can be made.
* [Bug Report](#bug) - While using RTL, if you notice something is not working correctly create a bug report, by creating an issue.
* [Feature Request](#feature) - While using RTL, if you feel that the software should be changed in certain way to make it for usable and helpful, create a feature request.
* [Testing](#testing) - Testing is one the easiest and most sought after method of contribution. Testing can be done on release branches, so that releases are relatively bug free.
* [Design](#design) - Design inputs can be made based on user enhancement suggestions or novel ideas which you get while using RTL.
* [Code](#code) - Development contributions are made via making coding changes to the software and getting it tested, reviewed and merged.
* [Code Review](#codereview) - Code review contributions are made by reviewing the code changes submitted via PRs to address bugs or feature requests
#### <a name="bug"></a>Bug Report
Bug reports are reports of technical or functional issues with the software. Bug reports help with the removal of defects from the software and improve its quality. Guidelines for submitting a bug report:
* Label the bug with the correct Lightning implementation (LND/Core Lightning/Eclair).
* Add the `Bug` label to the issue
* Provide details of your configuration like Device, Operating system, Bitcoin version, Lightning implementation version, RTL version etc.
* Attempt to explain the scenario in detail, so that the developer can try to replicate the issue at their end.
* If the bug is with the UI, screenshots help. Try to highlight the problem areas by circling with red outline.
* Take care to redact sensitive info from the screenshots like Pubkey or channel IDs etc.
* Be responsive to the developers requesting details on the issues.
#### <a name="feature"></a>Feature Request
Feature Requests are requests raised to add new features to the application. The features requests can range from technical to functional, making the application better for everyone. Guidelines to follow for create a feature request:
* Label the feature request with the correct Lightning implementation (LND/Core Lightning/Eclair).
* Add the `Enhancement Request` label to the issue
* If the feature relates to an existing aspect of the application, indicate clearly which part of the application the feature request relates to. E.g. Transactions page under Lightning menu.
* Provide the justification for the feature request. E.g. Privacy/Security/Usability benefit.
* If the feature request is technical in nature, try to provide the platform detail like OS, Lightning Implementation version etc.
* For new UI features mockups are helpful for the developers.
* Be responsive on the feature requests when developers request details or clarification and also help with the testing of the features requested.
#### <a name="testing"></a>Testing
Testing is the easiest and most effective method to contribute. It helps uncover bugs and improve the quality of software. Best time to test would be pre-release, when the changes are being made to the software for the next release. RTL maintains a release branch for the next planned release and changes are merge to the release branch on a regular basis. The testers can contribute by pulling from the release branch and testing the software. If issues are found during testing, follow the steps described above to raise bug reports to help address the issues.
#### <a name="design"></a>Design
Design suggestions are always welcome and helpful. Design suggestion can range from improving both the aesthetics as well as the UX of the application. We believe improving design and UX of the application is an ongoing journey. User feedback and bugs raised also provide insights into how both can be improved. if you would like to provide design related suggestions or contribute with design inputs, raise issues on the [Design repo of RTL](https://github.com/Ride-The-Lightning/RTL-Design) and follow the guidance provided there.
#### <a name="code"></a>Code
Contributions via code is the most sought after contribution and something we enthusiastically encourage. Follow the below guideline to be able to contribute code to RTL.
##### Pull Code
* Pull the code from the release (current eg Release-0.12.2) branch into your local workspace via github commandline/GUI.
##### Install Dependencies
* Assuming that nodejs (v14 & above) and npm are already installed on your local machine. Go into your RTL root folder and run `npm ci`.
* Use `npm ci --legacy-peer-deps` if there is any dependency conflict.
* Sometimes after installation, user receives a message from npm to fix dependency vulnerability by running `npm audit fix`. Please do not follow this step as it can break some of the working RTL code on your machine. We audit and fix these vulnerabilities as soon as possible at our end.
##### Node Backend Server for Development
* The RTL server code has been written in typescript and `npm run watchbackend` script can be used to compile and generate their javascript equivalents. Keep the script running to watch for realtime changes and compilation. `watchbackend` and `buildbackend` scripts get the configuration options from tsconfig, read .ts files from the `./server` folder and save the compiled .js and .map files in `./backend` folder.
* To run RTL node server in development mode, open another command window, go to workspace/RTL and excute `npm run server`. This will run the script named `server` defined in package.json. This script sets the node environment as development and starts the server from rtl.js. Nodemon restarts the node application when file changes in the directory are detected.
* This `server` script has been written for windows machine. Please update the script to set the `NODE_ENV=development` according to your machine's OS.
* To check all available scripts for the project, explore the `scripts` section of package.json.
![](../screenshots/node-server-dev.jpg)
##### Angular Frontend Server for Development
* The last step starts the node server but it cannot detect and update the code written in Angular. We run the angular development server separately while working on the frontend of the project and package the final build once the development is finished.
* To run the angular development server, go to workspace/RTL and run `npm run start`. It will start the angular server at default '4200' port and serve the application on localhost:4200.
![](../screenshots/angular-server-dev.jpg)
![](../screenshots/localhost-ui-dev.jpg)
##### Package Angular Build
* Run `npm run test` script to verify and fix, if needed, automated test cases.
* Execute `npm run lint` to lint the code before final compilation.
* To compile the backend code, `npm run buildbackend` script should be used. It will compile the code written in typescript in `server` folder and create a folder named `backend` with final compiled javascript code.
* The Angular application code needs to be compiled into the output directory named `frontend` at workspace/RTL. It can be done by running `npm run buildfrontend` command in the RTL root.
* Please make sure to remove all linting and other errors thrown by the build command before moving to the next step.
![](../screenshots/angular-build.jpg)
##### Create a Pull Request
* Create a new branch on the github to push your updated code.
* Commit your updates into the newly created branch.
* Create a new pull request once you are satisfied with your updates to be merged into the latest `release` branch with details of your updates and submit it for the review.
##### Caution about adding new libraries
* We are conservative in adding new dependencies to the repository. Do your best to not add any new libraries on RTL. We believe this is the best strategy to keep the software safe from vulnerabilites.
* Confirm before starting by creating an issue about adding the library
* The library should be popular, well maintained and pre-existing vulnerability free.
##### Handling Dependabot PRs (dependency updates)
Dependabot files its security PRs against `master`, but they are not merged individually: they conflict with each other on `package-lock.json`, and `master` only advances when a release is merged. Instead, all open Dependabot alerts are resolved together in a single dependency-update PR against the current release branch (see [#1633](https://github.com/Ride-The-Lightning/RTL/pull/1633) for an example). The process:
1. **Collect the targets.** Gather the fixed versions from every open Dependabot PR. Also review `npm audit` for findings *without* an open Dependabot PR — many are fixable in the same pass, and exact version pins in `package.json` can hide an available in-range fix for a direct dependency (check `fixAvailable` in `npm audit --json`).
2. **Apply the bumps.** Update the pins in `package.json` for direct dependencies (Dependabot's validated version for runtime deps; the latest patch of the same minor for build tooling). Keep all `@angular/*` framework packages on a single version, and the CLI line (`@angular/cli`, `@angular/build`, `@angular-devkit/build-angular`) on its own matching version — Angular is under devDependencies but is compiled into the shipped frontend bundle. Never run a blanket `npm audit fix`.
3. **Regenerate the lockfile from scratch.** Delete `package-lock.json` and run `npm install --legacy-peer-deps`. This produces one clean, fully re-resolved tree instead of an incrementally patched lockfile, and typically picks up additional in-range fixes for deep transitive dependencies.
4. **Rebuild the compiled outputs.** Run `npm run buildbackend && npm run buildfrontend` and commit the regenerated `backend/` and `frontend/` artifacts along with `package.json` and `package-lock.json`, so the shipped bundles match the updated dependency tree.
5. **Verify before opening the PR.** `npm run lint`, `npm run test`, and a functional check against real nodes — the regtest fixture under `docker/` covers all three implementations (see `docker/README.md`).
6. **Open one PR** against the current release branch with a release-note entry summarizing the before/after `npm audit` counts. Once the release branch is merged to `master`, Dependabot closes its superseded PRs automatically.
Vulnerabilities in deprecated packages (e.g. an unmaintained dependency with no fixed release) cannot be resolved by version bumps — track those in a dedicated issue for a code-level replacement instead of leaving them in the batch PR.

View file

@ -1,4 +1,4 @@
ARG BASE_DISTRO="node:20-alpine"
ARG BASE_DISTRO="node:22-alpine"
FROM --platform=${BUILDPLATFORM} ${BASE_DISTRO} AS builder
@ -7,7 +7,7 @@ WORKDIR /RTL
COPY package.json /RTL/package.json
COPY package-lock.json /RTL/package-lock.json
RUN npm install --legacy-peer-deps
RUN npm ci --legacy-peer-deps
COPY . .

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 Shahana Farooqui
Copyright (c) 2018-2026 Shahana Farooqui
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { getAlias } from './network.js';
@ -14,11 +14,21 @@ export const listPeerChannels = (req, res, next) => {
options.url = req.session.selectedNode.settings.lnServerUrl + '/v1/listpeerchannels';
request.post(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Peer Channels List Received', data: body.channels });
return Promise.all(body.channels?.map((channel) => {
if (!body.channels || body.channels.length === 0) {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'No Channels to Process' });
return res.status(200).json([]);
}
const getPeerAliasesTasks = body.channels.map((channel) => () => {
channel.to_them_msat = channel.total_msat - channel.to_us_msat;
channel.balancedness = (channel.total_msat === 0) ? 1 : (1 - Math.abs((channel.to_us_msat - (channel.total_msat - channel.to_us_msat)) / channel.total_msat)).toFixed(3);
channel.balancedness = (channel.total_msat === 0) ? 1 : (1 - Math.abs((channel.to_us_msat - channel.to_them_msat) / channel.total_msat)).toFixed(3);
// listpeerchannels reports connection state as peer_connected. Mirror it onto the
// documented legacy 'connected' field (see the Channel model) as a real boolean, so
// any backward-compat consumer of this endpoint gets a defined true/false rather than
// undefined when peer_connected is absent (issue #1606).
channel.connected = !!channel.peer_connected;
return getAlias(req.session.selectedNode, channel, 'peer_id');
})).then((values) => {
});
common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Peer Channels List With Aliases Received', data: body.channels });
return res.status(200).json(body.channels || []);
});

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { CLWSClient } from './webSocketClient.js';

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,9 +1,14 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
const logger = Logger;
const common = Common;
// Alias cache: peerId -> { alias, ts }. Bounded by a TTL so an updated node alias is picked
// up without an RTL restart, and by a max size so it can't grow unbounded (evicts oldest).
const ALIAS_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours
const ALIAS_CACHE_MAX = 5000;
const aliasCache = new Map();
export const getRoute = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Network', msg: 'Getting Network Routes..' });
options = common.getOptions(req);
@ -14,9 +19,21 @@ export const getRoute = (req, res, next) => {
options.body = req.body;
request.post(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Network', msg: 'Network Routes Received', data: body });
return Promise.all(body.route?.map((rt) => getAlias(req.session.selectedNode, rt, 'id'))).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Network Routes with Alias Received', data: body });
res.status(200).json(body || []);
// Resolve hop aliases with a bounded number of concurrent listnodes calls, matching the
// peers/channels paths, so a long route can't storm clnrest (#1501).
const getRouteAliasesTasks = (body.route || []).map((rt) => () => getAlias(req.session.selectedNode, rt, 'id'));
common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, () => {
// Guard the response-send: the limiter invokes this outside the surrounding .catch.
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Network Routes with Alias Received', data: body });
res.status(200).json(body || []);
}
catch (e) {
const err = common.handleError(e, 'Network', 'Query Routes Error', req.session.selectedNode);
if (!res.headersSent) {
res.status(err.statusCode).json({ message: err.message, error: err.error });
}
}
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Network', 'Query Routes Error', req.session.selectedNode);
@ -80,20 +97,46 @@ export const listNodes = (req, res, next) => {
});
};
export const getAlias = (selNode, peer, id) => {
options.url = selNode.settings.lnServerUrl + '/v1/listnodes';
if (!peer[id]) {
const peerId = peer[id];
if (!peerId) {
logger.log({ selectedNode: selNode, level: 'ERROR', fileName: 'Network', msg: 'Empty Peer ID' });
peer.alias = '';
return peer;
return Promise.resolve(peer);
}
options.body = { id: peer[id] };
return request.post(options).then((body) => {
const cached = aliasCache.get(peerId);
if (cached && (Date.now() - cached.ts) < ALIAS_CACHE_TTL) {
peer.alias = cached.alias;
return Promise.resolve(peer);
}
// Build a self-contained request from the selected node's own auth options rather than the
// shared module-level 'options', which is only set by a prior network.ts endpoint call. That
// coupling meant a cold Peers/route lookup (no prior network call) dereferenced a null 'options'
// and threw; now that the limiter swallows per-task throws, that surfaced as a 200 with every
// alias unset (#1501 review F1). selNode.authentication.options is guaranteed present here
// because every caller runs getOptions() first.
const nodeOptions = selNode.authentication?.options;
if (!nodeOptions || !nodeOptions.headers) {
peer.alias = peerId.substring(0, 20);
return Promise.resolve(peer);
}
const aliasOptions = { ...nodeOptions, method: 'POST', url: selNode.settings.lnServerUrl + '/v1/listnodes', body: { id: peerId }, json: true, qs: {} };
delete aliasOptions.form;
return request.post(aliasOptions).then((body) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Network', msg: 'Peer Alias Finished', data: body });
peer.alias = body.nodes[0] && body.nodes[0].alias ? body.nodes[0].alias : peer[id].substring(0, 20);
const alias = body.nodes?.[0]?.alias || peerId.substring(0, 20);
// Re-insert so a refreshed entry moves to the most-recent position, then evict the
// oldest if we're over the cap (Map preserves insertion order).
aliasCache.delete(peerId);
aliasCache.set(peerId, { alias, ts: Date.now() });
if (aliasCache.size > ALIAS_CACHE_MAX) {
aliasCache.delete(aliasCache.keys().next().value);
}
peer.alias = alias;
return peer;
}).catch((errRes) => {
common.handleError(errRes, 'Network', 'Peer Alias Error', selNode);
peer.alias = peer[id].substring(0, 20);
const alias = peerId.substring(0, 20);
peer.alias = alias;
return peer;
});
};

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { Database } from '../../utils/database.js';

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { Database } from '../../utils/database.js';

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { getAlias } from './network.js';
@ -15,9 +15,23 @@ export const getPeers = (req, res, next) => {
request.post(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Peers', msg: 'Peers List Received', data: body });
const peers = !body.peers ? [] : body.peers;
return Promise.all(peers?.map((peer) => getAlias(req.session.selectedNode, peer, 'id'))).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Sorted Peers List Received', data: body.peers });
res.status(200).json(body.peers || []);
// Resolve peer aliases with a bounded number of concurrent listnodes calls. An unbounded
// Promise.all here fires one request per peer at once, which overwhelms clnrest on nodes
// with many peers and fails with "Resource temporarily unavailable (os error 11)" (#1501).
const getPeerAliasesTasks = peers.map((peer) => () => getAlias(req.session.selectedNode, peer, 'id'));
common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => {
// The limiter invokes this outside the surrounding .then/.catch chain, so guard the
// response-send: a throw here would otherwise be an unhandled rejection with no response.
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Sorted Peers List Received', data: body.peers });
res.status(200).json(body.peers || []);
}
catch (e) {
const err = common.handleError(e, 'Peers', 'List Peers Error', req.session.selectedNode);
if (!res.headersSent) {
res.status(err.statusCode).json({ message: err.message, error: err.error });
}
}
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Peers', 'List Peers Error', req.session.selectedNode);
@ -38,8 +52,21 @@ export const postPeer = (req, res, next) => {
listOptions.url = req.session.selectedNode.settings.lnServerUrl + '/v1/listpeers';
request.post(listOptions).then((listPeersRes) => {
const peers = listPeersRes && listPeersRes.peers ? common.newestOnTop(listPeersRes.peers, 'id', connectRes.id) : [];
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Peers List after Connect Received', data: peers });
res.status(201).json(peers);
// Resolve aliases (bounded) for the returned peers so a freshly connected peer shows its
// alias rather than a raw node id, matching getPeers and the LND postPeer path (#1629 F5).
const getPeerAliasesTasks = peers.map((peer) => () => getAlias(req.session.selectedNode, peer, 'id'));
common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Peers List after Connect Received', data: peers });
res.status(201).json(peers);
}
catch (e) {
const err = common.handleError(e, 'Peers', 'Connect Peer Error', req.session.selectedNode);
if (!res.headersSent) {
res.status(err.statusCode).json({ message: err.message, error: err.error });
}
}
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Peers', 'Connect Peer Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -44,7 +44,7 @@ export const verifyMessage = (req, res, next) => {
}
options.url = req.session.selectedNode.settings.lnServerUrl + '/v1/checkmessage';
options.body = req.body;
request.post(options, (error, response, body) => {
request.post(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Message', msg: 'Message Verified', data: body });
res.status(201).json(body);
}).catch((errRes) => {

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { createInvoiceRequestCall, listPendingInvoicesRequestCall } from './invoices.js';
@ -53,7 +53,10 @@ export const getChannels = (req, res, next) => {
options.form = req.query;
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form });
}
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options });
// Log the call's shape only. Eclair authenticates with HTTP basic auth, so the options
// object carries the node's lnApiPassword in its authorization header, and node logs are
// routinely shared when debugging.
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: { url: options.url, form: options.form } });
if (common.read_dummy_data) {
common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); });
}
@ -68,7 +71,7 @@ export const getChannels = (req, res, next) => {
}
else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Empty Channels List Received' });
res.status(200).json([]);
return res.status(200).json([]);
}
}).
catch((errRes) => {

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { ECLWSClient } from './webSocketClient.js';

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -96,7 +96,7 @@ export const queryPaymentRoute = (req, res, next) => {
}
else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Payments', msg: 'Empty Payment Route Information Received' });
res.status(200).json({ routes: [] });
return res.status(200).json({ routes: [] });
}
}).catch((errRes) => {
const err = common.handleError(errRes, 'Payments', 'Query Route Error', req.session.selectedNode);

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -43,7 +43,7 @@ export const getPeers = (req, res, next) => {
}
else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Empty Peers Received' });
res.status(200).json([]);
return res.status(200).json([]);
}
}).
catch((errRes) => {
@ -95,7 +95,7 @@ export const connectPeer = (req, res, next) => {
});
}
else {
res.status(201).json([]);
return res.status(201).json([]);
}
}).catch((errRes) => {
const err = common.handleError(errRes, 'Peers', 'Connect Peer Error', req.session.selectedNode);

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,13 +1,13 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
const logger = Logger;
const common = Common;
export const getAliasForChannel = (selNode, channel) => {
export const getAliasForChannel = (selNode, channel, requestOptions) => {
const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : '';
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((aliasBody) => {
requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(requestOptions).then((aliasBody) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias });
channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20);
return channel;
@ -30,18 +30,26 @@ export const getAllChannels = (req, res, next) => {
request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body });
if (body.channels) {
return Promise.all(body.channels?.map((channel) => {
body.channels.forEach((channel) => {
local = (channel.local_balance) ? +channel.local_balance : 0;
remote = (channel.remote_balance) ? +channel.remote_balance : 0;
total = local + remote;
channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3);
return getAliasForChannel(req.session.selectedNode, channel);
})).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
return res.status(200).json(body);
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get All Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getChannelAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
common.runWithConcurrencyLimit(getChannelAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get All Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get All Channel Aliases Error', error: e.message });
}
}
});
}
else {
@ -66,26 +74,32 @@ export const getPendingChannels = (req, res, next) => {
if (!body.total_limbo_balance) {
body.total_limbo_balance = 0;
}
const promises = [];
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getPendingAliasesTasks = [];
if (body.pending_open_channels && body.pending_open_channels.length > 0) {
body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) {
body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.pending_closing_channels && body.pending_closing_channels.length > 0) {
body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
if (body.waiting_close_channels && body.waiting_close_channels.length > 0) {
body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel)));
body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
}
return Promise.all(promises).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
return res.status(200).json(body);
}).
catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get Pending Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
common.runWithConcurrencyLimit(getPendingAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Pending Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Pending Channel Aliases Error', error: e.message });
}
}
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode);
@ -102,15 +116,23 @@ export const getClosedChannels = (req, res, next) => {
options.qs = req.query;
request(options).then((body) => {
if (body.channels && body.channels.length > 0) {
return Promise.all(body.channels?.map((channel) => {
body.channels.forEach((channel) => {
channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type;
return getAliasForChannel(req.session.selectedNode, channel);
})).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
return res.status(200).json(body);
}).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Get Closed Channel Aliases Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getClosedAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
common.runWithConcurrencyLimit(getClosedAliasesTasks, 20, () => {
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Closed Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Closed Channel Aliases Error', error: e.message });
}
}
});
}
else {
@ -140,7 +162,7 @@ export const postChannel = (req, res, next) => {
options.form.target_conf = trans_type_value;
}
else if (trans_type === '2') {
options.form.sat_per_byte = trans_type_value;
options.form.sat_per_vbyte = trans_type_value;
}
if (commitment_type) {
options.form.commitment_type = commitment_type;
@ -171,11 +193,17 @@ export const closeChannel = (req, res, next) => {
if (req.query.target_conf) {
options.url = options.url + '&target_conf=' + req.query.target_conf;
}
if (req.query.sat_per_byte) {
options.url = options.url + '&sat_per_byte=' + req.query.sat_per_byte;
if (req.query.sat_per_vbyte) {
options.url = options.url + '&sat_per_vbyte=' + req.query.sat_per_vbyte;
}
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Closing Channel Options URL', data: options.url });
request.delete(options);
// Fire-and-forget: LND keeps the close stream open until the closing tx
// confirms, so exempt it from the request timeout; the 202 is already sent,
// so log a rejection instead of letting it crash the process.
request.delete({ ...options, timeout: 0 }).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'Close Channel Error', req.session.selectedNode);
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Close Channel Error', error: err });
});
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Channel Close Requested' });
res.status(202).json({ message: 'Close channel request has been submitted.' });
}

View file

@ -1,6 +1,6 @@
import * as fs from 'fs';
import { sep } from 'path';
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -9,10 +9,10 @@ const common = Common;
function getFilesList(channelBackupPath, callback) {
const files_list = [];
let all_restore_exists = false;
let response = { all_restore_exists: false, files: [] } || { message: '', error: {}, statusCode: 500 };
let response = { all_restore_exists: false, files: [] };
fs.readdir(channelBackupPath + sep + 'restore', (err, files) => {
if (err && err.code !== 'ENOENT' && err.errno !== -4058) {
response = { message: 'Channels Restore List Failed!', error: err, statusCode: 500 };
response = Object.assign({ message: 'Channels Restore List Failed!', error: err, statusCode: 500 });
}
if (files && files.length > 0) {
files.forEach((file) => {

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { getAllForwardingEvents } from './switch.js';

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { LNDWSClient } from './webSocketClient.js';

View file

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

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
import { LNDWSClient } from './webSocketClient.js';
@ -6,6 +6,22 @@ let options = null;
const logger = Logger;
const common = Common;
const lndWsClient = LNDWSClient;
const KEYSEND_MESSAGE_TLV_TYPE = '34349334';
const extractKeysendMessage = (invoice) => {
if (invoice.is_keysend && (!invoice.memo || invoice.memo === '') && invoice.htlcs && invoice.htlcs.length > 0) {
for (const htlc of invoice.htlcs) {
if (htlc.custom_records && htlc.custom_records[KEYSEND_MESSAGE_TLV_TYPE]) {
try {
return Buffer.from(htlc.custom_records[KEYSEND_MESSAGE_TLV_TYPE], 'base64').toString('utf8');
}
catch (err) {
return '';
}
}
}
}
return invoice.memo || '';
};
export const invoiceLookup = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Invoice', msg: 'Getting Invoice Information..' });
options = common.getOptions(req);
@ -23,6 +39,7 @@ export const invoiceLookup = (req, res, next) => {
body.r_preimage = body.r_preimage ? Buffer.from(body.r_preimage, 'base64').toString('hex') : '';
body.r_hash = body.r_hash ? Buffer.from(body.r_hash, 'base64').toString('hex') : '';
body.description_hash = body.description_hash ? Buffer.from(body.description_hash, 'base64').toString('hex') : null;
body.memo = extractKeysendMessage(body);
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Invoice', msg: 'Invoice Information Received', data: body });
res.status(200).json(body);
}).catch((errRes) => {
@ -45,6 +62,7 @@ export const listInvoices = (req, res, next) => {
invoice.r_preimage = invoice.r_preimage ? Buffer.from(invoice.r_preimage, 'base64').toString('hex') : '';
invoice.r_hash = invoice.r_hash ? Buffer.from(invoice.r_hash, 'base64').toString('hex') : '';
invoice.description_hash = invoice.description_hash ? Buffer.from(invoice.description_hash, 'base64').toString('hex') : null;
invoice.memo = extractKeysendMessage(invoice);
});
}
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Invoice', msg: 'Sorted Invoices List Received', data: body });

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -89,6 +89,9 @@ export const paymentLookup = (req, res, next) => {
return res.status(options.statusCode).json({ message: options.message, error: options.error });
}
options.url = req.session.selectedNode.settings.lnServerUrl + '/v2/router/track/' + req.params.paymentHash;
// Deliberately keep the wrapper's default timeout here: this holds a
// browser-facing response open while tracking, and payments in flight
// longer than that are delivered via the websocket subscription instead.
request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Payments', msg: 'Payment Information Received for ' + req.params.paymentHash, data: body });
res.status(200).json(body.result || body);
@ -108,10 +111,13 @@ export const sendPayment = (req, res, next) => {
req.body.last_hop_pubkey = Buffer.from(req.body.last_hop_pubkey, 'hex').toString('base64');
}
req.body.amp = req.body.amp ?? false;
req.body.timeout_seconds = req.body.timeout_seconds || 600;
req.body.timeout_seconds = (Number.isFinite(+req.body.timeout_seconds) && +req.body.timeout_seconds > 0) ? +req.body.timeout_seconds : 600;
options.form = JSON.stringify(req.body);
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Payments', msg: 'Send Payment Options', data: options.form });
request.post(options).then((body) => {
// LND ends the stream at timeout_seconds with a FAILURE_REASON_TIMEOUT result;
// give the transport a margin over that so LND's mapped failure always wins
// the race against the wrapper's own timeout.
request.post({ ...options, timeout: (+req.body.timeout_seconds + 60) * 1000 }).then((body) => {
const results = body.split('\n').filter(Boolean).map((jsonString) => JSON.parse(jsonString));
body = results.length > 0 ? results[results.length - 1] : { result: { status: 'UNKNOWN' } };
if (body.result.status === 'FAILED') {

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -25,9 +25,21 @@ export const getPeers = (req, res, next) => {
request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Peers', msg: 'Peers List Received', data: body });
const peers = !body.peers ? [] : body.peers;
return Promise.all(peers?.map((peer) => getAliasForPeers(req.session.selectedNode, peer))).then((values) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Sorted Peers List Received', data: body.peers });
res.status(200).json(body.peers);
// Bound concurrent alias lookups so a node with many peers can't fire one graph/node
// request per peer at once and overwhelm the backend (parity with the CLN fix, #1501).
const getPeerAliasesTasks = peers.map((peer) => () => getAliasForPeers(req.session.selectedNode, peer));
common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => {
// Guard the response-send: the limiter invokes this outside the surrounding .catch.
try {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Sorted Peers List Received', data: body.peers });
res.status(200).json(body.peers);
}
catch (e) {
const err = common.handleError(e, 'Peers', 'List Peers Error', req.session.selectedNode);
if (!res.headersSent) {
res.status(err.statusCode).json({ message: err.message, error: err.error });
}
}
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Peers', 'List Peers Error', req.session.selectedNode);
@ -51,15 +63,24 @@ export const postPeer = (req, res, next) => {
options.url = req.session.selectedNode.settings.lnServerUrl + '/v1/peers';
request(options).then((body) => {
const peers = (!body.peers) ? [] : body.peers;
return Promise.all(peers?.map((peer) => getAliasForPeers(req.session.selectedNode, peer))).then((values) => {
if (body.peers) {
body.peers = common.newestOnTop(body.peers, 'pub_key', pubkey);
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Peers List after Connect Received', data: body });
// Bound concurrent alias lookups (parity with the CLN fix, #1501).
const getPeerAliasesTasks = peers.map((peer) => () => getAliasForPeers(req.session.selectedNode, peer));
common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => {
// Guard the response-send: the limiter invokes this outside the surrounding .catch, and
// this replaced an explicit inner .catch — a throw here must not hang the POST (#1629 F4).
try {
if (body.peers) {
body.peers = common.newestOnTop(body.peers, 'pub_key', pubkey);
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Peers List after Connect Received', data: body });
}
res.status(201).json(body.peers);
}
catch (e) {
const err = common.handleError(e, 'Peers', 'Connect Peer Error', req.session.selectedNode);
if (!res.headersSent) {
res.status(err.statusCode).json({ message: err.message, error: err.error });
}
}
res.status(201).json(body.peers);
}).catch((errRes) => {
const err = common.handleError(errRes, 'Peers', 'Connect Peer Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
});
}).catch((errRes) => {
const err = common.handleError(errRes, 'Peers', 'Connect Peer Error', req.session.selectedNode);

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -30,7 +30,7 @@ export const postTransactions = (req, res, next) => {
options.form = {
amount: amount,
addr: address,
sat_per_byte: fees,
sat_per_vbyte: fees,
target_conf: blocks
};
if (sendAll) {

View file

@ -1,5 +1,5 @@
import atob from 'atob';
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -120,7 +120,7 @@ export const getUTXOs = (req, res, next) => {
});
};
export const bumpFee = (req, res, next) => {
const { txid, outputIndex, targetConf, satPerByte } = req.body;
const { txid, outputIndex, targetConf, satPerVByte } = req.body;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Wallet', msg: 'Bumping Fee..' });
options = common.getOptions(req);
if (options.error) {
@ -135,8 +135,8 @@ export const bumpFee = (req, res, next) => {
if (targetConf) {
options.form.target_conf = targetConf;
}
else if (satPerByte) {
options.form.sat_per_byte = satPerByte;
else if (satPerVByte) {
options.form.sat_per_vbyte = satPerVByte;
}
options.form = JSON.stringify(options.form);
request.post(options).then((body) => {

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import * as fs from 'fs';
import { join } from 'path';
import { Logger } from '../../utils/logger.js';
@ -44,7 +44,9 @@ export class LNDWebSocketClient {
this.subscribeToInvoice = (options, selectedNode, rHash) => {
rHash = rHash?.replace(/\+/g, '-')?.replace(/[/]/g, '_');
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Invoice ' + rHash + ' ..' });
options.url = selectedNode.settings.lnServerUrl + '/v2/invoices/subscribe/' + rHash;
// Copy the options: the caller may pass the session-cached object, and the
// long poll needs an unbounded timeout without leaking it to other calls.
options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/invoices/subscribe/' + rHash, timeout: 0 };
request(options).then((msg) => {
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Invoice Information Received for ' + rHash });
if (typeof msg === 'string') {
@ -67,7 +69,9 @@ export class LNDWebSocketClient {
};
this.subscribeToPayment = (options, selectedNode, paymentHash) => {
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Payment ' + paymentHash + ' ..' });
options.url = selectedNode.settings.lnServerUrl + '/v2/router/track/' + paymentHash;
// Copy the options: the long poll needs an unbounded timeout without
// leaking it to other calls sharing the object.
options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/router/track/' + paymentHash, timeout: 0 };
request(options).then((msg) => {
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Payment Information Received for ' + paymentHash });
msg['type'] = 'payment';

View file

@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken';
import * as fs from 'fs';
import { sep } from 'path';
import { resolve, sep } from 'path';
import ini from 'ini';
import parseHocon from 'hocon-parser';
import request from 'request-promise';
import request from '../../utils/request.js';
import { Database } from '../../utils/database.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
@ -76,7 +76,23 @@ export const getCurrencyRates = (req, res, next) => {
};
export const getFile = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' });
const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak');
const channelBackupPath = req.session.selectedNode.settings.channelBackupPath;
let file = '';
if (req.query.path) {
// The UI only ever requests channel backup files; contain caller paths to the node's
// backup directory so this endpoint cannot read the config, macaroons or the SSO
// cookie (getConfig serves the config file masked; this must not bypass that).
const resolved = resolve(req.query.path);
if (resolved !== resolve(channelBackupPath) && !resolved.startsWith(resolve(channelBackupPath) + sep)) {
logger.log({ selectedNode: req.session.selectedNode, level: 'WARN', fileName: 'RTLConf', msg: 'Blocked file read outside the channel backup directory', data: req.query.path });
const err = common.handleError({ statusCode: 403, message: 'Reading File Error', error: 'File path is outside the channel backup directory' }, 'RTLConf', 'Reading File Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error });
}
file = resolved;
}
else {
file = channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak';
}
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'Channel Point', data: req.query.channel });
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file });
fs.readFile(file, 'utf8', (errRes, data) => {
@ -89,7 +105,8 @@ export const getFile = (req, res, next) => {
return res.status(err.statusCode).json({ message: err.error, error: err.error });
}
else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data });
// File contents can carry node credentials; never write them to the log.
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received' });
res.status(200).json(data);
}
});
@ -109,7 +126,6 @@ export const getApplicationSettings = (req, res, next) => {
delete appConfData.SSO.rtlCookiePath;
delete appConfData.SSO.cookieValue;
delete appConfData.SSO.logoutRedirectLink;
appConfData.secret2FA = '';
appConfData.dbDirectoryPath = '';
appConfData.nodes[selNodeIdx].authentication = new Authentication();
delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath;
@ -197,30 +213,51 @@ export const getConfig = (req, res, next) => {
export const updateNodeSettings = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Updating Node Settings..' });
const RTLConfFile = common.appConfig.rtlConfFilePath + sep + 'RTL-Config.json';
const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8'));
const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index));
if (node && node.settings) {
node.settings = req.body.settings;
if (req.body.authentication.boltzMacaroonPath) {
node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
}
else {
delete node.authentication.boltzMacaroonPath;
}
if (req.body.authentication.swapMacaroonPath) {
node.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath;
}
else {
delete node.authentication.swapMacaroonPath;
}
}
try {
const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8'));
const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index));
if (node && node.settings) {
// channelBackupPath anchors getFile's containment root and is documented as a
// config-file-only setting; accepting it from the API would let the caller being
// contained choose the containment base. Pin it to the server-held value.
const serverChannelBackupPath = node.settings.channelBackupPath;
node.settings = { ...node.settings, ...req.body.settings };
node.settings.channelBackupPath = serverChannelBackupPath;
if (node.authentication && req.body.authentication) {
if (req.body.authentication.boltzMacaroonPath) {
node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
}
else {
delete node.authentication.boltzMacaroonPath;
}
if (req.body.authentication.swapMacaroonPath) {
node.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath;
}
else {
delete node.authentication.swapMacaroonPath;
}
}
}
fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8');
const selectedNode = common.findNode(req.session.selectedNode.index);
if (selectedNode && selectedNode.settings) {
selectedNode.settings = req.body.settings;
selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
selectedNode.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath;
const serverChannelBackupPath = selectedNode.settings.channelBackupPath;
selectedNode.settings = { ...selectedNode.settings, ...req.body.settings };
selectedNode.settings.channelBackupPath = serverChannelBackupPath;
if (selectedNode.authentication && req.body.authentication) {
if (req.body.authentication.boltzMacaroonPath) {
selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
}
else {
delete selectedNode.authentication.boltzMacaroonPath;
}
if (req.body.authentication.swapMacaroonPath) {
selectedNode.authentication.swapMacaroonPath = req.body.authentication.swapMacaroonPath;
}
else {
delete selectedNode.authentication.swapMacaroonPath;
}
}
common.replaceNode(req, selectedNode);
}
let responseNode = JSON.parse(JSON.stringify(common.selectedNode));
@ -238,17 +275,82 @@ export const updateApplicationSettings = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Updating Application Settings..' });
const RTLConfFile = common.appConfig.rtlConfFilePath + sep + 'RTL-Config.json';
try {
const config = common.addSecureData(req.body);
common.appConfig = JSON.parse(JSON.stringify(config));
delete config.selectedNodeIndex;
delete config.enable2FA;
delete config.allowPasswordUpdate;
delete config.rtlConfFilePath;
delete config.rtlPass;
fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8');
const 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));
const oldConfig = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8'));
const config = common.addSecureData(JSON.parse(JSON.stringify(req.body)));
const runtimeConfig = oldConfig;
Object.keys(config).forEach((key) => {
if (key !== 'nodes') {
runtimeConfig[key] = config[key];
}
});
if (config.nodes && config.nodes.length > 0) {
const oldNodes = (common.appConfig.nodes && common.appConfig.nodes.length > 0) ? common.appConfig.nodes : (oldConfig.nodes || []);
const newNodesMap = new Map(config.nodes.map((node) => [node.index, node]));
const updatedAndExistingNodes = oldNodes.map((oldNode) => {
const newNode = newNodesMap.get(oldNode.index);
newNodesMap.delete(oldNode.index);
const node = newNode ? {
...oldNode,
...newNode,
authentication: { ...(oldNode.authentication || {}), ...(newNode.authentication || {}) },
settings: { ...(oldNode.settings || {}), ...(newNode.settings || {}) }
} : {
...oldNode,
authentication: { ...(oldNode.authentication || {}) },
settings: { ...(oldNode.settings || {}) }
};
return node;
});
const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode)));
runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes];
}
const newAppConfig = JSON.parse(JSON.stringify({
...runtimeConfig,
selectedNodeIndex: config.selectedNodeIndex !== undefined ?
config.selectedNodeIndex : common.appConfig.selectedNodeIndex,
enable2FA: config.enable2FA !== undefined ?
config.enable2FA : common.appConfig.enable2FA,
allowPasswordUpdate: config.allowPasswordUpdate !== undefined ?
config.allowPasswordUpdate : common.appConfig.allowPasswordUpdate,
rtlConfFilePath: common.appConfig.rtlConfFilePath,
rtlPass: common.appConfig.rtlPass
}));
const fileConfig = JSON.parse(JSON.stringify(newAppConfig));
delete fileConfig.selectedNodeIndex;
delete fileConfig.enable2FA;
delete fileConfig.allowPasswordUpdate;
delete fileConfig.rtlConfFilePath;
delete fileConfig.rtlPass;
delete fileConfig.multiPass;
// Runtime-only SSO bearer; must not be persisted with the config.
if (fileConfig.SSO) {
delete fileConfig.SSO.cookieValue;
}
fileConfig.nodes?.forEach((node) => {
delete node.authentication?.options;
delete node.authentication?.runeValue;
});
// Persist atomically (temp file + rename, so a mid-write failure cannot truncate the
// config) and only then adopt the new runtime config, so a failed write leaves the
// process on the old one. The temp file inherits the existing file's mode so a
// hardened 0600 is not silently downgraded; a fresh file gets 0600. Symlinks and
// single-file bind mounts cannot be renamed over — fall back to an in-place write,
// which preserves inode and mode.
const tempConfigFile = RTLConfFile + '.tmp';
try {
fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
fs.chmodSync(tempConfigFile, fs.existsSync(RTLConfFile) ? (fs.statSync(RTLConfFile).mode & 0o777) : 0o600);
fs.renameSync(tempConfigFile, RTLConfFile);
}
catch {
fs.rmSync(tempConfigFile, { force: true, recursive: true });
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
}
common.appConfig = newAppConfig;
// removeSecureData clones, so the runtime config is untouched; it strips rtlPass,
// the TOTP seed, the SSO cookie and all per-node credentials symmetrically.
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.removeSecureData(newAppConfig) });
res.status(201).json(common.removeSecureData(newAppConfig));
}
catch (errRes) {
const errMsg = 'Update Default Node Error';

View file

@ -19,6 +19,9 @@ const loginInterval = setInterval(() => {
}
}
}, LOCKING_PERIOD);
// The sweeper must not hold the event loop open on its own (it would keep
// `node --test` or a CLI invocation alive for the full 30-minute period).
loginInterval.unref();
export const getFailedInfo = (reqIP, currentTime) => {
let failed = { count: 0, lastTried: currentTime };
if ((!failedLoginAttempts[reqIP]) || (currentTime > (failed.lastTried + LOCKING_PERIOD))) {
@ -45,10 +48,33 @@ 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..' });
if (+common.appConfig.SSO.rtlSSO) {
if (!!common.appConfig.disableAuth) {
if (!req.session.selectedNode) {
req.session.selectedNode = common.selectedNode;
}
const token = jwt.sign({ user: 'AUTH_DISABLED_USER' }, common.secret_key);
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'User Disabled Authentication' });
res.status(200).json({ token: token });
}
else if (+common.appConfig.SSO.rtlSSO) {
if (authenticateWith === 'JWT' && jwt.verify(authenticationValue, common.secret_key)) {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'User Authenticated' });
res.status(406).json({ message: 'SSO Authentication Error', error: 'Login with Password is not allowed with SSO.' });
@ -76,8 +102,15 @@ export const authenticateUser = (req, res, next) => {
const failed = getFailedInfo(reqIP, currentTime);
const password = authenticationValue;
if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) {
if (twoFAToken && twoFAToken !== '') {
if (!verifyToken(twoFAToken)) {
// Gate on the server-side 2FA configuration, not on the request: when 2FA is
// enabled a token is mandatory, so a request omitting twoFAToken is rejected
// instead of silently skipping verification. The login UI keys its token prompt
// on enable2FA, so both fields are consulted — a stale secret with 2FA disabled
// must not lock the operator out of a UI that never prompts for a token.
// Requests with a valid session token (in-app re-authorization, e.g. the
// password prompt before on-chain sends) are exempt from the TOTP requirement.
if (common.appConfig.enable2FA && common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && !hasValidAuthToken(req)) {
if (typeof twoFAToken !== 'string' || twoFAToken === '' || !verifyToken(twoFAToken)) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } });
failed.count = failed.count + 1;
failed.lastTried = currentTime;

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;
@ -79,7 +79,7 @@ export const getSwapInfo = (req, res, next) => {
});
};
export const createSwap = (req, res, next) => {
const { amount, sendFromInternal, address } = req.body;
const { amount, sendFromInternal, address, refundAddress } = req.body;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Boltz', msg: 'Creating Swap..' });
options = common.getBoltzServerOptions(req);
if (options.url === '') {
@ -88,7 +88,7 @@ export const createSwap = (req, res, next) => {
return res.status(err.statusCode).json({ message: err.message, error: err.error });
}
options.url = options.url + '/v1/createswap';
options.body = { amount: amount };
options.body = { amount: amount, refundAddress };
if (sendFromInternal) {
options.body.send_from_internal = sendFromInternal;
}

View file

@ -1,4 +1,4 @@
import request from 'request-promise';
import request from '../../utils/request.js';
import { Logger } from '../../utils/logger.js';
import { Common } from '../../utils/common.js';
let options = null;

View file

@ -40,11 +40,12 @@ export class Authentication {
}
}
export class ApplicationConfig {
constructor(defaultNodeIndex, selectedNodeIndex, dbDirectoryPath, rtlConfFilePath, rtlPass, multiPass, multiPassHashed, allowPasswordUpdate, enable2FA, secret2FA, SSO, nodes) {
constructor(defaultNodeIndex, selectedNodeIndex, dbDirectoryPath, rtlConfFilePath, disableAuth, rtlPass, multiPass, multiPassHashed, allowPasswordUpdate, enable2FA, secret2FA, SSO, nodes) {
this.defaultNodeIndex = defaultNodeIndex;
this.selectedNodeIndex = selectedNodeIndex;
this.dbDirectoryPath = dbDirectoryPath;
this.rtlConfFilePath = rtlConfFilePath;
this.disableAuth = disableAuth;
this.rtlPass = rtlPass;
this.multiPass = multiPass;
this.multiPassHashed = multiPassHashed;

View file

@ -3,7 +3,8 @@ const { Router } = exprs;
import { isAuthenticated } from '../../utils/authCheck.js';
import { genSeed, updateSelNodeOptions, getUTXOs, operateWallet, bumpFee, labelTransaction, leaseUTXO, releaseUTXO } from '../../controllers/lnd/wallet.js';
const router = Router();
router.get('/genseed/:passphrase?', isAuthenticated, genSeed);
router.get('/genseed', isAuthenticated, genSeed);
router.get('/genseed/:passphrase', isAuthenticated, genSeed);
router.get('/updateSelNodeOptions', isAuthenticated, updateSelNodeOptions);
router.get('/getUTXOs', isAuthenticated, getUTXOs);
router.post('/wallet/:operation', isAuthenticated, operateWallet);

View file

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

View file

@ -37,17 +37,21 @@ export class ExpressApplication {
this.app.use(this.common.baseHref + '/api/ecl', eclRoutes);
this.app.use(this.common.baseHref, express.static(join(this.directoryName, '../..', 'frontend')));
this.app.use((req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''); // RTL Angular Frontend
res.setHeader('XSRF-TOKEN', req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''); // RTL Quickpay JQuery
// Generate the token once per request: with csrf-csrf every call mints a
// new token on a first visit, so calling twice would desync the cookie
// from the header and the _csrf cookie it must match.
const csrfToken = req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : '';
res.cookie('XSRF-TOKEN', csrfToken); // RTL Angular Frontend
res.setHeader('XSRF-TOKEN', csrfToken); // RTL Quickpay JQuery
res.sendFile(join(this.directoryName, '../..', 'frontend', 'index.html'));
});
this.app.use((err, req, res, next) => {
this.handleApplicationErrors(err, res);
this.handleApplicationErrors(err, req, res);
next();
});
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'App', msg: 'Application Routes Set' });
};
this.handleApplicationErrors = (err, res) => {
this.handleApplicationErrors = (err, req, res) => {
switch (err.code) {
case 'EACCES':
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'App', msg: 'Server requires elevated privileges' });
@ -62,6 +66,16 @@ export class ExpressApplication {
res.status(401).send('Server is down/locked.');
break;
case 'EBADCSRFTOKEN':
// Re-mint the token for the current session so a client retry succeeds
// (the stale one may be bound to a destroyed session or rotated secret).
try {
const csrfToken = CSRF.reMintToken(req, res);
res.cookie('XSRF-TOKEN', csrfToken);
res.setHeader('XSRF-TOKEN', csrfToken);
}
catch (csrfError) {
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'App', msg: 'CSRF Token Re-Mint Failed', error: csrfError });
}
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'App', msg: 'Invalid CSRF token. Form tempered.' });
res.status(403).send('Invalid CSRF token, form tempered.');
break;

View file

@ -1,10 +1,10 @@
import jwt from 'jsonwebtoken';
import csurf from 'csurf/index.js';
import CSRF from './csrf.js';
import { Common } from './common.js';
import { Logger } from './logger.js';
const common = Common;
const logger = Logger;
const csurfProtection = csurf({ cookie: true });
const csurfProtection = CSRF.csrfProtection;
export const isAuthenticated = (req, res, next) => {
try {
const token = req.headers.authorization.split(' ')[1];

View file

@ -2,7 +2,7 @@ import * as fs from 'fs';
import { join, dirname, isAbsolute, resolve, sep } from 'path';
import { fileURLToPath } from 'url';
import * as crypto from 'crypto';
import request from 'request-promise';
import request from './request.js';
import { Logger } from './logger.js';
export class CommonService {
constructor() {
@ -22,22 +22,37 @@ export class CommonService {
{ name: 'JUL', days: 31 }, { name: 'AUG', days: 31 }, { name: 'SEP', days: 30 }, { name: 'OCT', days: 31 }, { name: 'NOV', days: 30 }, { name: 'DEC', days: 31 }
];
this.maskPasswords = (obj) => {
const keys = Object.keys(obj);
const length = keys.length;
if (length !== 0) {
for (let i = 0; i < length; i++) {
if (typeof obj[keys[i]] === 'object') {
keys[keys[i]] = this.maskPasswords(obj[keys[i]]);
}
if (typeof keys[i] === 'string' &&
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
keys[i].toLowerCase().includes('rpcuser'))) {
obj[keys[i]] = '*'.repeat(20);
// Clone up front: masking a live config object must not blank the credentials LN
// requests authenticate with (mirrors removeSecureData).
const masked = JSON.parse(JSON.stringify(obj));
const maskRecursive = (current) => {
const keys = Object.keys(current);
const length = keys.length;
if (length !== 0) {
for (let i = 0; i < length; i++) {
// Header maps always carry credentials in this codebase (macaroon, rune, basic
// auth). Key-substring matching cannot catch them without also hiding the *Path
// fields the settings UI legitimately shows, so mask the whole map.
if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') {
Object.keys(current[keys[i]]).forEach((headerKey) => { current[keys[i]][headerKey] = '*'.repeat(20); });
}
else if (current[keys[i]] && typeof current[keys[i]] === 'object') {
// Truthiness guard: null is 'object' too and must not reach Object.keys.
maskRecursive(current[keys[i]]);
}
if (typeof keys[i] === 'string' &&
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('rpcauth') ||
keys[i].toLowerCase().includes('secret2fa') || keys[i].toLowerCase().includes('cookievalue') ||
keys[i].toLowerCase().includes('rtlpass') || keys[i].toLowerCase().includes('runevalue'))) {
current[keys[i]] = '*'.repeat(20);
}
}
}
}
return obj;
return current;
};
return maskRecursive(masked);
};
this.removeAuthSecureData = (node) => {
if (node.authentication) {
@ -50,38 +65,70 @@ export class CommonService {
return node;
};
this.removeSecureData = (config) => {
delete config.rtlConfFilePath;
delete config.rtlPass;
delete config.multiPass;
delete config.multiPassHashed;
delete config.secret2FA;
config.nodes?.map((node) => this.removeAuthSecureData(node));
return config;
// Clone before deleting: cookieValue is runtime-only, so mutating a caller's live
// appConfig would destroy SSO state with no way to restore it.
const sanitized = JSON.parse(JSON.stringify(config));
delete sanitized.rtlConfFilePath;
delete sanitized.rtlPass;
delete sanitized.multiPass;
delete sanitized.multiPassHashed;
delete sanitized.secret2FA;
// The SSO cookie is a live bearer credential; it must never leave the server.
if (sanitized.SSO) {
delete sanitized.SSO.cookieValue;
}
sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node));
return sanitized;
};
this.addSecureData = (config) => {
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
config.rtlPass = this.appConfig.rtlPass;
config.multiPassHashed = this.appConfig.multiPassHashed;
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath;
// Pin the hash only when the server holds one: on a default install's first boot the
// file already has multiPassHashed but the in-memory config does not, and pinning
// undefined would erase the only password from the file on save, bricking the boot.
if (this.appConfig.multiPassHashed) {
config.multiPassHashed = this.appConfig.multiPassHashed;
}
else {
delete config.multiPassHashed;
}
// Deployment-level switches are pinned to server-held values: the settings API must
// not flip the authentication mode (disableAuth, SSO) or move SSO fields, the
// password policy, or the database location; no UI flow writes them. Pinning the
// whole SSO object also means a trimmed or missing SSO object can never wipe server
// state.
config.disableAuth = this.appConfig.disableAuth;
config.allowPasswordUpdate = this.appConfig.allowPasswordUpdate;
config.dbDirectoryPath = this.appConfig.dbDirectoryPath;
config.SSO = JSON.parse(JSON.stringify(this.appConfig.SSO || {}));
if (this.appConfig.multiPass) {
config.multiPass = this.appConfig.multiPass;
}
if (config.secret2FA === this.appConfig.secret2FA) {
// Restore the TOTP seed when the client omits it — and when it sends an empty seed
// while still claiming 2FA is on (an inconsistent pair no honest flow produces).
// The settings UI's enable flow sends a non-empty seed; its disable flow sends an
// empty seed with enable2FA false. Both are honored.
if (config.secret2FA === undefined || (config.secret2FA === '' && config.enable2FA)) {
config.secret2FA = this.appConfig.secret2FA;
}
config.nodes.map((node, i) => {
if (this.appConfig && this.appConfig.nodes && this.appConfig.nodes.length > i && this.appConfig.nodes[i].authentication) {
if (this.appConfig.nodes[i].authentication.macaroonPath) {
node.authentication.macaroonPath = this.appConfig.nodes[i].authentication.macaroonPath;
// 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);
if (appConfigNode?.authentication) {
node.authentication = node.authentication || {};
if (appConfigNode.authentication.macaroonPath) {
node.authentication.macaroonPath = appConfigNode.authentication.macaroonPath;
}
if (this.appConfig.nodes[i].authentication.runePath) {
node.authentication.runePath = this.appConfig.nodes[i].authentication.runePath;
if (appConfigNode.authentication.runePath) {
node.authentication.runePath = appConfigNode.authentication.runePath;
}
if (this.appConfig.nodes[i].authentication.lnApiPassword) {
node.authentication.lnApiPassword = this.appConfig.nodes[i].authentication.lnApiPassword;
if (appConfigNode.authentication.lnApiPassword) {
node.authentication.lnApiPassword = appConfigNode.authentication.lnApiPassword;
}
}
return node;
});
return config;
};
@ -101,7 +148,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err });
}
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options Set' });
return swapOptions;
};
this.getBoltzServerOptions = (req) => {
@ -119,7 +166,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err });
}
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options Set' });
return boltzOptions;
};
this.getOptions = (req) => {
@ -165,7 +212,7 @@ export class CommonService {
}
}
if (req.session.selectedNode) {
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode, data: req.session.selectedNode.authentication.options });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode });
}
return { status: 200, message: 'Updated Successfully' };
}
@ -192,11 +239,11 @@ export class CommonService {
}
};
this.setOptions = (req) => {
if (this.nodes[0].authentication.options && this.nodes[0].authentication.options.headers) {
return;
}
if (this.nodes && this.nodes.length > 0) {
this.nodes.forEach((node) => {
if (node.authentication.options && node.authentication.options.headers) {
return;
}
node.authentication.options = {
url: '',
rejectUnauthorized: false,
@ -235,7 +282,7 @@ export class CommonService {
form: ''
};
}
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options });
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode });
});
this.updateSelectedNodeOptions(req);
}
@ -343,10 +390,11 @@ export class CommonService {
this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) });
let newErrorObj = { statusCode: 500, message: '', error: '' };
if (err.code && err.code === 'ENOENT') {
// The absolute path stays in the server log above but is not echoed to clients.
newErrorObj = {
statusCode: 500,
message: 'No such file or directory ' + (err.path ? err.path : ''),
error: 'No such file or directory ' + (err.path ? err.path : '')
message: 'No such file or directory',
error: 'No such file or directory'
};
}
else {
@ -608,6 +656,57 @@ export class CommonService {
const dataStr = foundDataLine ? foundDataLine.substring((foundDataLine.indexOf(search_string)) + search_string.length) : '{}';
return JSON.parse(dataStr);
};
this.runWithConcurrencyLimit = (tasks, limit, done) => {
const results = new Array(tasks?.length || 0);
// 'done' must fire exactly once. Guard it: multiple runNext() completions (e.g. several
// non-function task elements draining synchronously) must not send the response twice.
let finished = false;
const finish = () => {
if (finished) {
return;
}
finished = true;
done(results);
};
// No tasks: the start loop below never runs, so 'done' would never fire and the
// response would hang. Resolve immediately for empty lists (e.g. a node with no peers).
if (!tasks || tasks.length === 0) {
return finish();
}
let nextIndex = 0;
let activeCount = 0;
const runNext = () => {
if (nextIndex >= tasks.length) {
if (activeCount === 0) {
finish(); // all tasks are finished
}
return;
}
const currentIndex = nextIndex++;
activeCount++;
const task = tasks[currentIndex];
if (typeof task !== 'function') {
results[currentIndex] = { error: new Error('Invalid task at index ' + currentIndex) };
activeCount--;
runNext();
return;
}
Promise.resolve().then(() => task()).then((result) => {
results[currentIndex] = result;
}).catch((err) => {
results[currentIndex] = { error: err };
}).finally(() => {
activeCount--;
runNext();
});
};
// Normalize to at least 1: a non-positive limit would start no tasks, so 'done' (only
// reached from a task's finally) would never fire and the response would hang.
const startCount = Math.max(1, limit);
for (let i = 0; i < startCount && i < tasks.length; i++) {
runNext();
}
};
}
}
export const Common = new CommonService();

View file

@ -118,9 +118,18 @@ export class ConfigService {
this.validateNodeConfig = (config) => {
config.allowPasswordUpdate = true;
if ((process?.env?.RTL_SSO && +process?.env?.RTL_SSO === 0) || (typeof process?.env?.RTL_SSO === 'undefined' && +config.SSO.rtlSSO === 0)) {
if (process?.env?.APP_PASSWORD && process?.env?.APP_PASSWORD.trim() !== '') {
if (!!process?.env?.DISABLE_AUTH || !!config.disableAuth) {
config.allowPasswordUpdate = false;
config.enable2FA = false;
this.logger.log({ selectedNode: this.common.selectedNode, level: 'WARN', fileName: 'Config', msg: 'Authentication is Disabled via environment or config' });
if (process?.env?.APP_PASSWORD && process?.env?.APP_PASSWORD.trim() !== '') {
this.errMsg = this.errMsg + '\nRTL Password cannot be set with disabled authentication. Please remove disableAuth option or password.';
}
}
else if (process?.env?.APP_PASSWORD && process?.env?.APP_PASSWORD.trim() !== '') {
config.rtlPass = this.hash.update(process?.env?.APP_PASSWORD).digest('hex');
config.allowPasswordUpdate = false;
this.logger.log({ selectedNode: this.common.selectedNode, level: 'WARN', fileName: 'Config', msg: 'Passing APP_PASSWORD via environment is suggested for standalone RTL application' });
}
else if (config.multiPassHashed && config.multiPassHashed !== '') {
config.rtlPass = config.multiPassHashed;
@ -293,7 +302,9 @@ export class ConfigService {
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err });
}
this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log';
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) });
// maskPasswords keeps paths visible for debugging while redacting credential
// fields such as lnApiPassword before they reach the log file.
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(this.common.nodes[idx])) });
const log_file = this.common.nodes[idx].settings.logFile;
if (fs.existsSync(log_file || '')) {
fs.writeFile((log_file || ''), '', () => { });

View file

@ -1,11 +1,30 @@
import csurf from 'csurf/index.js';
import { doubleCsrf } from 'csrf-csrf';
import { Logger } from './logger.js';
import { Common } from './common.js';
class CSRF {
constructor() {
this.csrfProtection = csurf({ cookie: true });
this.logger = Logger;
this.common = Common;
// Signed double-submit-cookie protection (replaces the deprecated csurf).
// The signed token lives in the httpOnly '_csrf' cookie; the client echoes
// the same token (read from the XSRF-TOKEN cookie set in app.ts) in a
// header. The cookie is not secure-only because RTL commonly serves plain
// HTTP (matching the session cookie); token sources match what csurf
// accepted. The error code EBADCSRFTOKEN is handled in app.ts.
this.doubleCsrfUtilities = doubleCsrf({
getSecret: () => this.common.secret_key,
getSessionIdentifier: (req) => (req.session ? req.session.id : ''),
cookieName: '_csrf',
cookieOptions: { sameSite: 'strict', path: '/', secure: false, httpOnly: true },
getCsrfTokenFromRequest: (req) => (req.body && req.body._csrf) || (req.query && req.query._csrf) ||
req.headers['csrf-token'] || req.headers['xsrf-token'] ||
req.headers['x-csrf-token'] || req.headers['x-xsrf-token']
});
this.csrfProtection = this.doubleCsrfUtilities.doubleCsrfProtection;
// Force-mints a fresh token for the current session, discarding any token
// cookie bound to a previous session or boot secret (used by the
// EBADCSRFTOKEN error path in app.ts so a client retry succeeds).
this.reMintToken = (req, res) => this.doubleCsrfUtilities.generateCsrfToken(req, res, { overwrite: true });
}
mount(app) {
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'CSRF', msg: 'Setting up CSRF..' });

96
backend/utils/request.js Normal file
View file

@ -0,0 +1,96 @@
import axios from 'axios';
import * as https from 'https';
// Drop-in replacement for the deprecated request-promise, backed by axios.
// Accepts the same options shape used across the controllers ({ url, baseUrl,
// uri, qs, form, body, headers, rejectUnauthorized, json }), resolves with the
// response body directly and rejects with a plain, serializable object that
// mirrors request-promise's StatusCodeError/RequestError shape expected by
// CommonService.handleError. Auth headers are intentionally excluded from the
// rejected error so they can never leak into logs or API error responses.
const insecureAgent = new https.Agent({ rejectUnauthorized: false });
const buildConfig = (options, method) => {
const config = {
url: options.url && options.url !== '' ? options.url : options.uri,
method: method || options.method || 'GET',
headers: options.headers ? { ...options.headers } : {},
// Bound hung upstreams; 10 minutes accommodates the slowest legitimate
// operations (LND's /v2/router/send streams up to timeout_seconds=600 and
// slow CLN channel operations get req.setTimeout(600000) upstream).
// Callers can override per request; 0 disables the bound entirely, which
// the LND invoice/payment subscription streams need (open until settled).
timeout: options.timeout !== null && options.timeout !== undefined ? options.timeout : 600000
};
if (options.baseUrl) {
config.baseURL = options.baseUrl;
}
if (options.qs && Object.keys(options.qs).length > 0) {
config.params = options.qs;
}
if (options.rejectUnauthorized === false) {
config.httpsAgent = insecureAgent;
}
if (options.form !== null && options.form !== undefined) {
if (typeof options.form === 'string') {
// Pre-encoded (or raw JSON string for LND's wallet endpoints), send as-is.
config.data = options.form;
}
else {
const params = new URLSearchParams();
Object.entries(options.form).forEach(([key, value]) => {
if (value === null || value === undefined) {
return;
}
if (Array.isArray(value)) {
// Eclair parses list fields as comma-separated values; omit empty lists
// (request-promise's qs encoding also dropped them).
if (value.length > 0) {
params.append(key, value.join(','));
}
}
else {
params.append(key, String(value));
}
});
config.data = params;
}
config.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
else if (options.body !== null && options.body !== undefined) {
config.data = options.body;
}
if (options.json !== true) {
// Callers without json: true (block explorer, currency rates) JSON.parse the body themselves.
config.responseType = 'text';
config.transformResponse = [(data) => data];
}
return config;
};
const toRequestPromiseError = (err, config) => {
const errOptions = { url: config.url, method: config.method };
if (err.response) {
return {
name: 'StatusCodeError',
statusCode: err.response.status,
message: err.response.status + ' - ' + JSON.stringify(err.response.data),
error: err.response.data,
options: errOptions
};
}
const message = err.message && err.message !== '' ? err.message : err.code;
return {
name: 'RequestError',
message: message,
error: { code: err.code, message: message },
options: errOptions
};
};
const call = (options, method) => {
const config = buildConfig(options, method);
return axios.request(config).then((response) => response.data).catch((err) => Promise.reject(toRequestPromiseError(err, config)));
};
const request = (options) => call(options);
request.get = (options) => call(options, 'GET');
request.post = (options) => call(options, 'POST');
request.put = (options) => call(options, 'PUT');
request.delete = (options) => call(options, 'DELETE');
export default request;

View file

@ -1,18 +1,32 @@
BITCOIN_HOST=bitcoind
BITCOIN_PORT=18889
BITCOIN_RPC_USER=bitcoin
BITCOIN_RPC_PASSWORD=bitcoin
BITCOIN_RPC_PORT=18888
BITCOIN_ZMQ_TX_PORT=28888
BITCOIN_ZMQ_BLOCK_PORT=28889
LIGHTNING_HOST=lnd
LIGHTNING_PORT=9735
LIGHTNING_RPC_PORT=10009
LIGHTNING_REST_PORT=8080
LIGHTNING_LOOP_PORT=8081
RTL_PORT=3000
# Regtest dev fixture. NOT for production. Credentials here are throwaway.
COMPOSE_FILE=docker-compose.yml
COMPOSE_PROJECT_NAME=rtldev
# bitcoind. The rpcauth hash for these credentials is baked into docker-compose.yml;
# if you change the user/password here you must regenerate it (see README).
BITCOIN_HOST=bitcoind
BITCOIN_RPC_USER=rtldev
BITCOIN_RPC_PASSWORD=rtldev
BITCOIN_RPC_PORT=18443
BITCOIN_P2P_PORT=18444
BITCOIN_ZMQ_BLOCK_PORT=28334
BITCOIN_ZMQ_TX_PORT=28335
# LND. Ports are the container-internal ones (identical for every node);
# host-side mappings are assigned per node in docker-compose.yml.
LIGHTNING_REST_PORT=8080
LIGHTNING_RPC_PORT=10009
LIGHTNING_P2P_PORT=9735
# Host-side LND REST ports, one per node
ALICE_REST_PORT=8081
BOB_REST_PORT=8082
CAROL_REST_PORT=8083
# RTL. Must not be one of RTL's blacklisted weak passwords
# ('password', 'changeme', 'moneyprintergobrrr') or RTL forces a password change
# on every login and parks you on the auth settings screen. Set in
# rtl/RTL-Config.regtest.json as multiPass; this is only used for messages.
RTL_PORT=3000
RTL_PASSWORD=rtldev

View file

@ -1,96 +1,278 @@
# 1) RTL Docker Dev Setup
# RTL regtest dev fixture
### This is not suitable for production deployments. ONLY FOR DEVELOPMENT.
### NOT suitable for production. Development only. Every credential here is throwaway.
This `docker-compose` template launches `bitcoind`, `lnd` and `rtl` containers.
It is configured to run in **regtest** mode but can be modified to suit your needs.
### 1.1) Notes
- `bitcoind` is built from an Ubuntu repository and should not be used in production.
- `lnd` will not sync to chain until Bitcoin regtest blocks are generated (see below).
- `rtl` image is from the Docker Hub repository but you can change this to your needs.
- Various ports and configs can be adjusted in the `.env` or `docker-compose.yml` files.
## 1.2) How to run
It may take several minutes if containers need to be built.
1.2.1) From the terminal in this folder:
A self-contained regtest network for developing and testing RTL: `bitcoind`, three
LND nodes, a Core Lightning node, an Eclair node, and RTL wired to all five.
```
$ docker-compose up -d bitcoind
$ bin/b-cli generate 101
$ docker-compose up -d lnd rtl
alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol
cln --[ 4,000,000 sat ]--> alice
eclair --[ 3,500,000 sat ]--> bob
```
1.2.2) Check containers are up and running with:
```
$ docker-compose ps
## Topology
```mermaid
flowchart TB
subgraph chain["Chain backend"]
bitcoind["bitcoind (regtest)<br/>RPC · ZMQ rawblock/rawtx · ZMQ hashblock"]
end
subgraph ln["Lightning nodes"]
alice["alice (LND)"]
bob["bob (LND)<br/>forwards payments"]
carol["carol (LND)"]
cln["cln (Core Lightning)"]
eclair["eclair (Eclair)"]
end
alice =="5M sat"==> bob
bob =="3M sat"==> carol
cln =="4M sat"==> alice
eclair =="3.5M sat"==> bob
alice -.-> bitcoind
bob -.-> bitcoind
carol -.-> bitcoind
cln -.-> bitcoind
eclair -.->|"dedicated 'eclair' wallet<br/>+ hashblock ZMQ"| bitcoind
rtl["RTL<br/>localhost:3000"]
rtl -->|"REST + macaroon"| alice
rtl -->|"REST + macaroon"| bob
rtl -->|"REST + macaroon"| carol
rtl -->|"clnrest + rune"| cln
rtl -->|"HTTP API + basic auth"| eclair
```
1.2.3) Use the cli tools to get responses from the containers:
```
$ bin/ln-cli getinfo
$ bin/b-cli getblockchaininfo
Thick arrows are channels (opener → peer), dotted arrows the chain backend each node
uses, and solid arrows how RTL reaches each node.
bob sits in the middle so it accrues forwarding history, which is what gives RTL's
routing screens something to show. Two nodes would leave them empty. The `cln`
(Core Lightning) node gives RTL's CLN screens a real backend — it talks to RTL over
clnrest with rune auth. The `eclair` node does the same for RTL's Eclair screens —
RTL talks to its HTTP API with basic auth.
LND, bitcoind and Eclair images come from [Polar](https://lightningpolar.com); the Core
Lightning image is the official [`elementsproject/lightningd`](https://hub.docker.com/r/elementsproject/lightningd).
All are multi-arch (amd64 + arm64) and nothing is built locally, so this works on
Apple Silicon. (The official `acinq/eclair` image is amd64-only and its versioned tags
are years stale, which is why Polar's build of the same source is used instead.)
## Requirements
Docker with Compose v2 (`docker compose`, not `docker-compose`).
## Quick start
From this directory:
```bash
docker compose up -d # bitcoind, alice, bob, carol, cln, eclair, rtl
./scripts/seed.sh # fund, connect, open channels, make payments
```
1.2.4) View daemon logs as follows:
```
$ docker-compose logs bitcoind lnd rtl
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.
Tear down, discarding all state:
```bash
docker compose down -v
```
Once the containers are running you can access the RTL UI at http://localhost:3000
## What the seed creates
- Default password is `password`.
- Default host, port and password can be changed in `.env`.
| | |
|---|---|
| On-chain | 10,000,000 sats per node (LND) + 10,000,000 sats each on cln and eclair, confirmed |
| Channels | alice→bob 5,000,000 · bob→carol 3,000,000 · eclair→bob 3,500,000 sats (1,000,000 pushed each) · cln→alice 4,000,000 sats (no push) |
| Routed payments | 5 × alice→carol via bob (10k, 25k, 50k, 75k, 100k sats) |
| Direct payments | 2 × alice→bob (5k, 15k sats) · 2 × eclair→bob (8k, 18k sats) |
| Open invoices | 2 unpaid on carol (20k, 40k sats) · 1 unpaid on eclair (30k sats) |
| Personas | alice + bob + cln + eclair OPERATOR, carol MERCHANT |
When you are done you can destroy containers with:
```
$ docker-compose down -v
```
---
# 2) Stand alone RTL Setup
This is suitable when you already have a LND node running and configured.
## Determinism
## 2.1) From docker image pull
```
RTL_VERSION=0.12.0
docker run --name rtl -d -it \
-e RTL_CONFIG_PATH=/RTLConfig \
-v /path/to/RTLConfig/dir:/RTLConfig \
-v /path/to/macaroon/dir:/path/as/specified/in/RTLConfig \
-v /path/to/database/dir:/RTL/database \
-p 3000:3000/tcp \
shahanafarooqui/rtl:${RTL_VERSION}
Every amount and payment in `scripts/seed.sh` is fixed. A fresh run always produces
identical state, so screenshots taken before and after a change differ only by the
change. **Do not introduce randomness.**
The seed is deterministic but deliberately *not* idempotent — running it twice would
fund every node again and open a second set of channels. It refuses to run against an
already-seeded network. To start over:
```bash
docker compose down -v && docker compose up -d && ./scripts/seed.sh
```
## 2.2) From local docker build
### 2.2.1) Build the image locally
```
RTL_VERSION=0.12.0
docker build -t rtl:${RTL_VERSION} -f dockerfiles/Dockerfile .
```
### 2.2.2) Create .env file
Create an environment file with your required configurations. Sample .env:
```
RTL_CONFIG_PATH=/RTLConfig
LN_IMPLEMENTATION=LND
MACAROON_PATH=/LNDMacaroon
LN_SERVER_URL=https://host.docker.internal:8080
## Helpers
```bash
bin/b-cli getblockcount # bitcoin-cli
bin/b-cli -rpcwallet=rtldev getbalance
bin/ln-cli alice getinfo # lncli, node name required
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
```
### 2.2.3) Run the newly built image with .env configurations
```
RTL_VERSION=0.12.0
docker run -d -it \
-v /path/to/RTLConfig/dir:/RTLConfig \
-v /path/to/macaroon/dir:/LNDMacaroon \
-v /path/to/database/dir:/RTL/database \
--env-file=.env -p 3000:3000 rtl:${RTL_VERSION}
Logs:
```bash
docker compose logs -f rtl
docker compose logs alice
```
Once the container is running you can access the RTL UI at http://localhost:3000
## BTCPay SSO harness
---
@hashamadeus on Twitter
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
its config on startup, so an init container copies it into a volume rather than
bind-mounting it — a read-only mount makes RTL exit with `EROFS`, and a writable one
would let RTL modify a version-controlled file. The name is not `RTL-Config.json`
because `.gitignore` matches that bare filename at any depth.
**`lncli` needs `--lnddir=/home/lnd/.lnd`.** `docker compose exec` lands as root,
whose HOME is `/root`, but lnd's datadir is `/home/lnd/.lnd`. `bin/ln-cli` handles this.
**Changing bitcoind credentials.** `docker-compose.yml` carries an `-rpcauth` hash for
the `BITCOIN_RPC_USER` / `BITCOIN_RPC_PASSWORD` in `.env`. Changing them there is not
enough; regenerate the hash:
```bash
python3 - <<'EOF'
import hmac, hashlib
user, password, salt = "rtldev", "rtldev", "8a1f2c3d4e5b6a7c8d9e0f1a2b3c4d5e"
print(f"{user}:{salt}${hmac.new(salt.encode(), password.encode(), hashlib.sha256).hexdigest()}")
EOF
```
In `docker-compose.yml` the `$` must be written `$$` to escape Compose interpolation.
**Payments right after channel open will fail.** The channel graph has to reach alice
before she can route to carol. The seed waits for this; anything you script yourself
should too.
**Core Lightning auth uses a rune.** RTL talks to `cln` over clnrest and authenticates
with a rune, not a macaroon. `cln/create-rune.sh` — run from the `cln` healthcheck —
creates a master rune once the RPC is up and writes it as `LIGHTNING_RUNE="…"` to
`rtl.rune` in the shared `cln_data` volume; RTL reads it via the `runePath` in its config.
The healthcheck reports unhealthy until that file exists, so RTL (which waits on
`service_healthy`) starts only once the rune is ready. Because it runs on every
healthcheck tick (idempotent), a transient RPC-startup race just retries and self-heals
rather than wedging the stack. `--clnrest-host=0.0.0.0` is required for RTL (another
container) to reach clnrest; the default `127.0.0.1` would only be reachable from inside
the node.
**Eclair has no wallet of its own.** It drives a bitcoind wallet over RPC. The
`eclair-wallet-init` service creates a dedicated `eclair` wallet before the node starts;
without it eclair would attach to "the default loaded wallet" — the `rtldev` mining
wallet — and report the miner's balance as its own. RTL authenticates to eclair with
`lnApiPassword` (HTTP basic auth), no file mount needed. Eclair also confirms channels
at 8 blocks (`channel.min-depth-blocks`), not 6 — the seed mines accordingly. And its
`bitcoind.zmqblock` must point at a `zmqpubhashblock` endpoint — wired to the rawblock
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.

View file

@ -1,10 +1,20 @@
#!/usr/bin/env bash
#
# bitcoin-cli against the regtest fixture.
#
# bin/b-cli getblockchaininfo
# bin/b-cli -rpcwallet=rtldev getbalance
# bin/b-cli -rpcwallet=rtldev generatetoaddress 6 <address>
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck disable=SC1091
source .env
docker-compose exec bitcoind bitcoin-cli \
-datadir=/bitcoin \
-rpcuser=$BITCOIN_RPC_USER \
-rpcpassword=$BITCOIN_RPC_PASSWORD \
-rpcport=$BITCOIN_RPC_PORT \
"$@"
exec docker compose exec -T bitcoind bitcoin-cli \
-regtest \
-rpcuser="$BITCOIN_RPC_USER" \
-rpcpassword="$BITCOIN_RPC_PASSWORD" \
-rpcport="$BITCOIN_RPC_PORT" \
"$@"

20
docker/bin/e-cli Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
#
# eclair-cli against the regtest fixture's eclair node.
#
# bin/e-cli getinfo
# bin/e-cli channels
# bin/e-cli createinvoice --amountMsat=1000000 --description=test
#
# The API password is passed explicitly because 'docker compose exec' does not
# read eclair's config for auth; it defaults to the fixture's throwaway value.
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck disable=SC1091
source .env
exec docker compose exec -T eclair eclair-cli \
-p "${ECLAIR_API_PASSWORD:-rtldev}" \
"$@"

View file

@ -1,8 +1,32 @@
#!/usr/bin/env bash
#
# lncli against one node of the regtest fixture. The node name is required,
# because the fixture runs three of them.
#
# bin/ln-cli alice getinfo
# bin/ln-cli bob listchannels
# bin/ln-cli carol addinvoice --amt=1000
#
# --lnddir is passed explicitly: 'docker compose exec' lands as root, whose HOME
# is /root, but lnd's datadir is /home/lnd/.lnd. Without it lncli looks for the
# TLS cert in the wrong place and fails.
source .env
set -euo pipefail
docker-compose exec lnd lncli \
--macaroonpath /shared/admin.macaroon \
--tlscertpath /shared/tls.cert \
"$@"
cd "$(dirname "$0")/.."
node="${1:-}"
case "$node" in
alice|bob|carol)
shift
;;
*)
echo "usage: $(basename "$0") <alice|bob|carol> [lncli args...]" >&2
exit 1
;;
esac
exec docker compose exec -T "$node" lncli \
--network=regtest \
--lnddir=/home/lnd/.lnd \
"$@"

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

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

View file

@ -1,10 +0,0 @@
FROM ubuntu:18.04
RUN apt-get -qq update && apt-get install -y software-properties-common
RUN add-apt-repository -y ppa:bitcoin/bitcoin \
&& add-apt-repository -y universe && apt-get update
RUN apt-get install -y bitcoind
ADD ./bitcoin.conf /bitcoin/bitcoin.conf

View file

@ -1,2 +0,0 @@
daemon=0
printtoconsole=1

31
docker/cln/create-rune.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
#
# Ensure the RTL rune exists. One quick, idempotent attempt:
# - already have it -> succeed
# - RPC up, createrune works -> write it, succeed
# - RPC not ready / failure -> fail, so the caller retries
#
# This is invoked from the cln healthcheck (not a one-shot poststart hook) so a
# transient RPC-startup race self-heals on the next healthcheck tick instead of
# permanently wedging the stack. Stores the rune as LIGHTNING_RUNE="<rune>", the
# format RTL reads via its runePath. POSIX sh compatible.
set -u
# Hardcoded to match the single source of truth used everywhere else in the fixture:
# the cln volume mount (cln_data:/root/.lightning), the healthcheck's `test -f`, and
# RTL's runePath (/cln/rtl.rune, /cln being cln_data mounted read-only). Keep these in
# lockstep — do not switch to ${LIGHTNINGD_DATA}, which would silently diverge if the
# image's data dir ever changed while the mounts/healthcheck stayed on /root/.lightning.
RUNE_FILE="/root/.lightning/rtl.rune"
[ -f "${RUNE_FILE}" ] && exit 0
lightning-cli --network="${LIGHTNINGD_NETWORK}" getinfo >/dev/null 2>&1 || exit 1
rune=$(lightning-cli --network="${LIGHTNINGD_NETWORK}" createrune 2>/dev/null \
| grep -o '"rune"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed -e 's/.*"rune"[[:space:]]*:[[:space:]]*"//' -e 's/"$//')
[ -n "${rune}" ] || exit 1
printf 'LIGHTNING_RUNE="%s"\n' "${rune}" > "${RUNE_FILE}"

View file

@ -1,132 +1,400 @@
version: "2.4"
# Regtest dev fixture for RTL: bitcoind + 3 LND nodes + RTL.
#
# NOT suitable for production. All credentials are throwaway.
#
# Topology is alice -> bob -> carol, so bob forwards payments and RTL's
# routing/forwarding screens have data in them. See README.md.
#
# Node images come from Polar (https://lightningpolar.com), which publishes
# multi-arch (amd64 + arm64) builds. Nothing is built locally.
volumes:
bitcoin_data:
lightning_data:
lightning_shared:
bitcoind_data:
alice_data:
bob_data:
carol_data:
cln_data:
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
restart: unless-stopped
depends_on:
- bitcoind
services:
bitcoind:
container_name: ${COMPOSE_PROJECT_NAME}_bitcoind
image: bitcoind:0.19.0
build: ./bitcoind
command: [
"bitcoind",
"-datadir=/bitcoin",
"-port=${BITCOIN_PORT}",
"-upnp=0",
"-dnsseed=0",
"-txindex=1",
"-listen=0",
"-onlynet=ipv4",
"-regtest=1",
"-regtest.rpcport=${BITCOIN_RPC_PORT}",
"-regtest.port=${BITCOIN_PORT}",
"-rpcport=${BITCOIN_RPC_PORT}",
"-rpcuser=${BITCOIN_RPC_USER}",
"-rpcpassword=${BITCOIN_RPC_PASSWORD}",
"-rpcallowip=0.0.0.0/0",
"-zmqpubrawtx=tcp://0.0.0.0:${BITCOIN_ZMQ_TX_PORT}",
"-zmqpubrawblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT}",
"-zmqpubhashblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT}"
]
ports:
- "${BITCOIN_PORT}:${BITCOIN_PORT}"
volumes:
- bitcoin_data:/bitcoin
lnd:
container_name: ${COMPOSE_PROJECT_NAME}_lnd
image: lnd:0.12.0-beta
build: ./lnd
image: polarlightning/bitcoind:30.0
restart: unless-stopped
command:
- bitcoind
- -server=1
- -regtest=1
# rpcauth hash for ${BITCOIN_RPC_USER}/${BITCOIN_RPC_PASSWORD}. '$$' escapes
# compose interpolation and reaches bitcoind as a single '$'.
- -rpcauth=rtldev:8a1f2c3d4e5b6a7c8d9e0f1a2b3c4d5e$$010df4b32c5e9a556cba1857eb5865990c983d8a56dadc0fdbf457cf90073c6c
- -zmqpubrawblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT}
- -zmqpubrawtx=tcp://0.0.0.0:${BITCOIN_ZMQ_TX_PORT}
# eclair's zmqblock consumes the hashblock topic, not rawblock (LND uses
# rawblock/rawtx above); without this endpoint eclair never sees new
# blocks and channels stay in WAIT_FOR_FUNDING_CONFIRMED forever.
- -zmqpubhashblock=tcp://0.0.0.0:${BITCOIN_ZMQ_HASHBLOCK_PORT:-28336}
- -txindex=1
- -dnsseed=0
- -rpcbind=0.0.0.0
- -rpcallowip=0.0.0.0/0
- -rpcport=${BITCOIN_RPC_PORT}
- -listen=1
- -listenonion=0
- -fallbackfee=0.0002
ports:
- "${BITCOIN_RPC_PORT}:${BITCOIN_RPC_PORT}"
volumes:
- bitcoind_data:/home/bitcoin/.bitcoin
# --alias / --externalip / --tlsextradomain are per-node on purpose: the alias
# is what RTL displays, and the extradomain must match the hostname RTL dials
# (https://alice:8080) or TLS validation fails.
alice:
<<: *lnd
container_name: ${COMPOSE_PROJECT_NAME}_alice
command:
- lnd
- --noseedbackup
- --trickledelay=5000
- --alias=alice
- --externalip=alice
- --tlsextradomain=alice
- --tlsextradomain=${COMPOSE_PROJECT_NAME}_alice
- --tlsextradomain=host.docker.internal
- --listen=0.0.0.0:${LIGHTNING_P2P_PORT}
- --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}
- --restlisten=0.0.0.0:${LIGHTNING_REST_PORT}
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}
- --bitcoind.rpcuser=${BITCOIN_RPC_USER}
- --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}
- --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}
- --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}
- --accept-keysend
- --accept-amp
ports:
- "${ALICE_REST_PORT}:${LIGHTNING_REST_PORT}"
volumes:
- alice_data:/home/lnd/.lnd
bob:
<<: *lnd
container_name: ${COMPOSE_PROJECT_NAME}_bob
command:
- lnd
- --noseedbackup
- --trickledelay=5000
- --alias=bob
- --externalip=bob
- --tlsextradomain=bob
- --tlsextradomain=${COMPOSE_PROJECT_NAME}_bob
- --tlsextradomain=host.docker.internal
- --listen=0.0.0.0:${LIGHTNING_P2P_PORT}
- --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}
- --restlisten=0.0.0.0:${LIGHTNING_REST_PORT}
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}
- --bitcoind.rpcuser=${BITCOIN_RPC_USER}
- --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}
- --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}
- --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}
- --accept-keysend
- --accept-amp
ports:
- "${BOB_REST_PORT}:${LIGHTNING_REST_PORT}"
volumes:
- bob_data:/home/lnd/.lnd
carol:
<<: *lnd
container_name: ${COMPOSE_PROJECT_NAME}_carol
command:
- lnd
- --noseedbackup
- --trickledelay=5000
- --alias=carol
- --externalip=carol
- --tlsextradomain=carol
- --tlsextradomain=${COMPOSE_PROJECT_NAME}_carol
- --tlsextradomain=host.docker.internal
- --listen=0.0.0.0:${LIGHTNING_P2P_PORT}
- --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}
- --restlisten=0.0.0.0:${LIGHTNING_REST_PORT}
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}
- --bitcoind.rpcuser=${BITCOIN_RPC_USER}
- --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}
- --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}
- --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}
- --accept-keysend
- --accept-amp
ports:
- "${CAROL_REST_PORT}:${LIGHTNING_REST_PORT}"
volumes:
- carol_data:/home/lnd/.lnd
# Core Lightning node. Unlike the LND nodes it talks to RTL over clnrest (the
# built-in REST plugin) using rune auth, so it needs --clnrest-* options and a
# rune written where RTL can read it. create-rune.sh (run from the healthcheck)
# creates the rune and writes it to /root/.lightning/rtl.rune (RTL mounts that
# read-only); the healthcheck is unhealthy until it exists, so rtl waits for it.
# --clnrest-host=0.0.0.0 is required so the rtl container can reach it; the default
# 127.0.0.1 would only be reachable from inside this container. Protocol stays https
# (clnrest default, self-signed) — RTL connects with rejectUnauthorized:false.
cln:
image: elementsproject/lightningd:v25.09
container_name: ${COMPOSE_PROJECT_NAME}_cln
restart: unless-stopped
command: [
"lnd",
"--noseedbackup",
"--rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT}",
"--restlisten=0.0.0.0:${LIGHTNING_REST_PORT}",
"--adminmacaroonpath=/shared/admin.macaroon",
"--tlsextradomain=${LIGHTNING_HOST}",
"--tlsextraip=0.0.0.0",
"--tlscertpath=/shared/tls.cert",
"--datadir=/lnd",
"--bitcoin.active",
"--bitcoin.regtest",
"--bitcoin.node=bitcoind",
"--bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}",
"--bitcoind.rpcuser=${BITCOIN_RPC_USER}",
"--bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}",
"--bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}",
"--bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}"
]
depends_on:
- bitcoind
environment:
LIGHTNINGD_NETWORK: regtest
command:
- --alias=cln
- --bitcoin-rpcconnect=${BITCOIN_HOST}
- --bitcoin-rpcport=${BITCOIN_RPC_PORT}
- --bitcoin-rpcuser=${BITCOIN_RPC_USER}
- --bitcoin-rpcpassword=${BITCOIN_RPC_PASSWORD}
- --bitcoin-retry-timeout=3600
- --addr=0.0.0.0:9735
- --announce-addr=cln:9735
- --large-channels
- --clnrest-host=0.0.0.0
- --clnrest-port=3010
ports:
- "${LIGHTNING_REST_PORT}:${LIGHTNING_REST_PORT}"
- "${CLN_REST_PORT:-3010}:3010"
volumes:
- lightning_data:/lnd
- lightning_shared:/shared
- cln_data:/root/.lightning
- ./cln/create-rune.sh:/opt/create-rune.sh:ro
healthcheck:
# create-rune.sh ensures the rune exists (idempotent, one quick attempt) and
# this reports healthy only once it does. Driving it from the healthcheck — which
# retries on its interval — means a transient RPC-startup race self-heals instead
# of a one-shot script permanently wedging the stack. rtl waits on this via
# depends_on: condition: service_healthy before reading the rune at startup.
test: ["CMD-SHELL", "sh /opt/create-rune.sh && test -f /root/.lightning/rtl.rune"]
interval: 5s
timeout: 10s
retries: 40
boltz:
container_name: ${COMPOSE_PROJECT_NAME}_boltz
image: boltz:1.2.0
build: ./boltz
restart: unless-stopped
command: [
"boltz",
"--noseedbackup",
"--rpclisten=0.0.0.0:${BOLTZ_RPC_PORT}",
"--restlisten=0.0.0.0:${BOLTZ_REST_PORT}",
"--adminmacaroonpath=/shared/admin.macaroon",
"--tlsextradomain=${BOLTZ_HOST}",
"--tlsextraip=0.0.0.0",
"--tlscertpath=/shared/tls.cert",
"--datadir=/boltz",
"--bitcoin.active",
"--bitcoin.regtest",
"--bitcoin.node=bitcoind",
"--bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT}",
"--bitcoind.rpcuser=${BITCOIN_RPC_USER}",
"--bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD}",
"--bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}",
"--bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT}"
]
# Eclair has no on-chain wallet of its own -- it drives a bitcoind wallet over
# RPC. Without a dedicated wallet it would grab "the default loaded wallet",
# which here is the rtldev mining wallet, and eclair's on-chain balance would
# show the miner's coins. This init container creates (or reloads) a wallet
# named "eclair" before the eclair node starts; load_on_startup survives
# bitcoind restarts.
eclair-wallet-init:
container_name: ${COMPOSE_PROJECT_NAME}_eclair_wallet_init
image: polarlightning/bitcoind:30.0
depends_on:
- bitcoind
entrypoint: ["/bin/sh", "-c"]
command:
- |
bcli() { bitcoin-cli -regtest -rpcconnect=${BITCOIN_HOST} -rpcport=${BITCOIN_RPC_PORT} -rpcuser=${BITCOIN_RPC_USER} -rpcpassword=${BITCOIN_RPC_PASSWORD} "$$@"; }
i=0
until bcli getblockchaininfo >/dev/null 2>&1; do
i=$$((i+1)); [ "$$i" -ge 60 ] && echo "bitcoind never came up" && exit 1
sleep 1
done
bcli -named createwallet wallet_name=eclair load_on_startup=true >/dev/null 2>&1 \
|| bcli loadwallet eclair true >/dev/null 2>&1 \
|| true
bcli -rpcwallet=eclair getwalletinfo >/dev/null
echo "eclair wallet ready"
# Eclair node. Talks to RTL over its HTTP API with basic auth (the
# lnApiPassword in RTL's config). The polarlightning image is used because
# acinq/eclair on Docker Hub is amd64-only and its newest versioned tag is
# years stale; Polar builds the same ACINQ source multi-arch (amd64 + arm64).
# The image entrypoint translates each --key=value into -Declair.key=value.
# The entrypoint also overrides server.public-ips.0 with the container IP, but
# the arg must still be present for that substitution to happen.
eclair:
image: polarlightning/eclair:0.13.1
container_name: ${COMPOSE_PROJECT_NAME}_eclair
restart: unless-stopped
depends_on:
bitcoind:
condition: service_started
eclair-wallet-init:
condition: service_completed_successfully
command:
- polar-eclair
- --node-alias=eclair
- --server.public-ips.0=eclair
- --server.port=9735
- --api.enabled=true
- --api.binding-ip=0.0.0.0
- --api.port=8080
- --api.password=${ECLAIR_API_PASSWORD:-rtldev}
- --chain=regtest
- --bitcoind.host=${BITCOIN_HOST}
- --bitcoind.rpcport=${BITCOIN_RPC_PORT}
- --bitcoind.rpcuser=${BITCOIN_RPC_USER}
- --bitcoind.rpcpassword=${BITCOIN_RPC_PASSWORD}
# zmqblock must be bitcoind's *hashblock* endpoint (eclair subscribes to
# that topic); pointing it at the rawblock endpoint the LND nodes use
# leaves eclair blind to new blocks.
- --bitcoind.zmqblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_HASHBLOCK_PORT:-28336}
- --bitcoind.zmqtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT}
- --bitcoind.wallet=eclair
- --datadir=/home/eclair/.eclair
- --printToConsole=true
# Regtest feerates are far from mainnet estimates; without a wide
# tolerance eclair closes channels over feerate disagreements.
- --on-chain-fees.feerate-tolerance.ratio-low=0.00001
- --on-chain-fees.feerate-tolerance.ratio-high=10000.0
ports:
- "${BOLTZ_REST_PORT}:${BOLTZ_REST_PORT}"
- "${ECLAIR_REST_PORT:-8281}:8080"
volumes:
- boltz_data:/boltz
- boltz_shared:/shared
- eclair_data:/home/eclair
# RTL rewrites its config file on startup, so it cannot be given the tracked
# template directly: a bind mount would either be read-only (RTL exits with
# EROFS) or would let RTL scribble into a version-controlled file. Instead the
# template is copied into a volume that 'down -v' discards, which keeps the
# source pristine and every run starting from identical config.
rtl-config-init:
container_name: ${COMPOSE_PROJECT_NAME}_rtl_config_init
image: busybox:1.36
command: >
sh -c "cp /template/RTL-Config.regtest.json /config/RTL-Config.json &&
chmod 644 /config/RTL-Config.json &&
echo 'config staged'"
volumes:
- ./rtl/RTL-Config.regtest.json:/template/RTL-Config.regtest.json:ro
- rtl_config:/config
rtl:
container_name: ${COMPOSE_PROJECT_NAME}_rtl
image: shahanafarooqui/rtl:0.12.0
# 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}
restart: unless-stopped
depends_on:
- lnd
volumes:
- lightning_shared:/shared:ro
- rtl_db:/database
rtl-config-init:
condition: service_completed_successfully
alice:
condition: service_started
bob:
condition: service_started
carol:
condition: service_started
cln:
condition: service_healthy
eclair:
condition: service_started
ports:
- "${RTL_PORT}:${RTL_PORT}"
environment:
PORT: ${RTL_PORT}
HOST: 192.168.0.27
MACAROON_PATH: /shared
LN_SERVER_URL: https://${LIGHTNING_HOST}:${LIGHTNING_REST_PORT}
CONFIG_PATH: ''
LN_IMPLEMENTATION: LND
SWAP_SERVER_URL: https://${LIGHTNING_HOST}:${LIGHTNING_LOOP_PORT}
SWAP_MACAROON_PATH: /shared
BOLTZ_SERVER_URL: https://${BOLTZ_HOST}:${BOLTZ_PORT}
BOLTZ_MACAROON_PATH: /shared
RTL_SSO: 0
RTL_COOKIE_PATH: ''
LOGOUT_REDIRECT_LINK: ''
RTL_CONFIG_PATH: /RTL
BITCOIND_CONFIG_PATH: ''
CHANNEL_BACKUP_PATH: /shared/lnd/backup
ENABLE_OFFERS: false
ENABLE_PEERSWAP: false
RTL_CONFIG_PATH: /RTL/config
volumes:
- rtl_config:/RTL/config
- alice_data:/lnd/alice:ro
- bob_data:/lnd/bob:ro
- 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

View file

@ -1,29 +0,0 @@
FROM golang:1.11-alpine as builder
WORKDIR /go/src/github.com/lightningnetwork/lnd
# Force Go to use the cgo based DNS resolver. This is required to ensure DNS
# queries required to connect to linked containers succeed.
ENV GODEBUG netdns=cgo
RUN apk add --no-cache --update alpine-sdk git make \
&& git clone -n https://github.com/lightningnetwork/lnd . \
&& git checkout d2186cc9da29853091175189268b073f49586cf0 \
&& make \
&& make install
# Start a new, final image to reduce size.
FROM alpine as final
# Expose lnd ports (server, rpc).
EXPOSE 9735 10009
# Copy the binaries and entrypoint from the builder image.
COPY --from=builder /go/bin/lncli /bin/
COPY --from=builder /go/bin/lnd /bin/
# Add bash.
RUN apk add --no-cache bash
# Import the config
ADD ./lnd.conf /root/.lnd/lnd.conf

View file

@ -1,2 +0,0 @@
[Application Options]
debuglevel=info

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

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

View file

@ -0,0 +1,103 @@
{
"multiPass": "rtldev",
"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"
}
},
{
"index": 2,
"lnNode": "bob",
"lnImplementation": "LND",
"authentication": {
"macaroonPath": "/lnd/bob/data/chain/bitcoin/regtest"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://bob:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
},
{
"index": 3,
"lnNode": "carol",
"lnImplementation": "LND",
"authentication": {
"macaroonPath": "/lnd/carol/data/chain/bitcoin/regtest"
},
"settings": {
"userPersona": "MERCHANT",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://carol:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
},
{
"index": 4,
"lnNode": "cln",
"lnImplementation": "CLN",
"authentication": {
"runePath": "/cln/rtl.rune"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://cln:3010",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
},
{
"index": 5,
"lnNode": "eclair",
"lnImplementation": "ECL",
"authentication": {
"lnApiPassword": "rtldev"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "http://eclair:8080",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
}
]
}

View file

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

367
docker/scripts/seed.sh Executable file
View file

@ -0,0 +1,367 @@
#!/usr/bin/env bash
#
# Seed the regtest fixture with a deterministic scenario.
#
# Every amount, capacity and payment below is fixed on purpose. Re-running this
# against a fresh network must produce the same state, so that screenshots taken
# now and after a redesign differ only by the design. Do not introduce randomness.
#
# Topology:
#
# alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol
# cln --[ 4,000,000 sat ]--> alice
# eclair --[ 3,500,000 sat ]--> bob
#
# bob sits in the middle so it accrues forwarding history, which is what
# populates RTL's routing screens.
#
# Usage: ./scripts/seed.sh (from the docker/ directory)
set -euo pipefail
cd "$(dirname "$0")/.."
# Docker Compose reads .env by itself; bash does not. Without this the defaults
# below silently win, and the summary at the end prints a password that does not
# work.
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
BITCOIN_RPC_USER="${BITCOIN_RPC_USER:-rtldev}"
BITCOIN_RPC_PASSWORD="${BITCOIN_RPC_PASSWORD:-rtldev}"
ECLAIR_API_PASSWORD="${ECLAIR_API_PASSWORD:-rtldev}"
NODES=(alice bob carol)
# Deterministic scenario constants
FUND_SATS=10000000 # on-chain funding per node
CH_ALICE_BOB=5000000 # channel capacity alice -> bob
CH_BOB_CAROL=3000000 # channel capacity bob -> carol
CH_CLN_ALICE=4000000 # channel capacity cln -> alice (Core Lightning node)
CH_ECL_BOB=3500000 # channel capacity eclair -> bob (Eclair node)
PUSH_SATS=1000000 # pushed to remote on open, so both sides have liquidity
MINE_CONFIRM=6 # blocks to confirm a funding tx
ECL_MINE_CONFIRM=8 # eclair's channel.min-depth-blocks default is 8, not 6
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\n\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
bcli() {
docker compose exec -T bitcoind bitcoin-cli -regtest \
-rpcuser="$BITCOIN_RPC_USER" -rpcpassword="$BITCOIN_RPC_PASSWORD" "$@"
}
# 'docker compose exec' lands as root, whose HOME is /root, but lnd's datadir is
# /home/lnd/.lnd -- so lncli must be told where to find the cert and macaroon.
lncli() {
local node=$1; shift
docker compose exec -T "$node" lncli --network=regtest --lnddir=/home/lnd/.lnd "$@"
}
# Core Lightning cli. Runs inside the cln container against the regtest node.
clncli() {
docker compose exec -T cln lightning-cli --network=regtest "$@"
}
# Eclair cli. Runs inside the eclair container; auths with the API password.
ecli() {
docker compose exec -T eclair eclair-cli -p "$ECLAIR_API_PASSWORD" "$@"
}
# Extract the first value for a JSON key from lncli output.
# 'first' matters: walletbalance reports confirmed_balance at the top level AND
# again under account_balance.default, and lncli emits no --json flag we can use.
json_first() {
grep -o "\"$1\": *\"[^\"]*\"" | head -1 | sed -e 's/^[^:]*: *"//' -e 's/"$//'
}
# Wait for a command to succeed, up to N attempts.
wait_for() {
local desc=$1 attempts=$2; shift 2
local i=1
while (( i <= attempts )); do
if "$@" >/dev/null 2>&1; then
info "$desc ready (${i}s)"
return 0
fi
sleep 1
(( i++ ))
done
die "timed out after ${attempts}s waiting for: $desc"
}
# ---------------------------------------------------------------- bitcoind
log "Waiting for bitcoind"
wait_for "bitcoind RPC" 60 bcli getblockchaininfo
log "Preparing wallet"
if ! bcli listwallets | grep -q '"rtldev"'; then
bcli createwallet rtldev >/dev/null 2>&1 || bcli loadwallet rtldev >/dev/null
fi
info "wallet rtldev present"
MINE_ADDR=$(bcli -rpcwallet=rtldev getnewaddress)
info "mining address: $MINE_ADDR"
HEIGHT=$(bcli getblockcount)
if (( HEIGHT < 101 )); then
log "Mining 101 blocks (coinbase maturity)"
bcli -rpcwallet=rtldev generatetoaddress 101 "$MINE_ADDR" >/dev/null
else
info "chain already at height $HEIGHT, skipping initial mine"
fi
# ---------------------------------------------------------------- lnd nodes
log "Waiting for LND nodes"
for n in "${NODES[@]}"; do
wait_for "$n" 120 lncli "$n" getinfo
done
# This script is deterministic, not idempotent: running it twice would fund every
# node again and open a second set of channels. Refuse rather than corrupt the
# fixture, since the whole point is that a fresh run reproduces identical state.
if lncli alice listchannels | grep -q '"chan_id"'; then
die "network is already seeded -- re-running would double-fund it.
Reset with: docker compose down -v && docker compose up -d && ./scripts/seed.sh"
fi
log "Funding nodes (${FUND_SATS} sats each)"
BTC_AMOUNT=$(awk "BEGIN{printf \"%.8f\", $FUND_SATS/100000000}")
for n in "${NODES[@]}"; do
addr=$(lncli "$n" newaddress p2wkh | json_first address)
[ -n "$addr" ] || die "could not get address for $n"
bcli -rpcwallet=rtldev sendtoaddress "$addr" "$BTC_AMOUNT" >/dev/null
info "$n <- $BTC_AMOUNT BTC ($addr)"
done
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm funding"
log "Waiting for confirmed on-chain balances"
for n in "${NODES[@]}"; do
for i in $(seq 1 60); do
bal=$(lncli "$n" walletbalance | json_first confirmed_balance)
bal=${bal:-0}
(( bal > 0 )) && { info "$n confirmed balance: $bal sats"; break; }
sleep 1
(( i == 60 )) && die "$n never saw confirmed funds"
done
done
# ---------------------------------------------------------------- peers
pubkey_of() {
lncli "$1" getinfo | json_first identity_pubkey
}
log "Connecting peers"
ALICE_PUB=$(pubkey_of alice)
BOB_PUB=$(pubkey_of bob)
CAROL_PUB=$(pubkey_of carol)
info "alice pubkey: $ALICE_PUB"
info "bob pubkey: $BOB_PUB"
info "carol pubkey: $CAROL_PUB"
lncli alice connect "${BOB_PUB}@bob:9735" >/dev/null 2>&1 || info "alice->bob already connected"
lncli bob connect "${CAROL_PUB}@carol:9735" >/dev/null 2>&1 || info "bob->carol already connected"
info "peers connected"
# ---------------------------------------------------------------- channels
log "Opening channels"
lncli alice openchannel --node_key="$BOB_PUB" \
--local_amt="$CH_ALICE_BOB" --push_amt="$PUSH_SATS" >/dev/null
info "alice -> bob ${CH_ALICE_BOB} sats (push ${PUSH_SATS})"
lncli bob openchannel --node_key="$CAROL_PUB" \
--local_amt="$CH_BOB_CAROL" --push_amt="$PUSH_SATS" >/dev/null
info "bob -> carol ${CH_BOB_CAROL} sats (push ${PUSH_SATS})"
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm channels"
log "Waiting for channels to become active"
for n in alice bob; do
for i in $(seq 1 60); do
active=$(lncli "$n" listchannels | grep -c '"active": *true' || true)
(( active > 0 )) && { info "$n has $active active channel(s)"; break; }
sleep 1
(( i == 60 )) && die "$n has no active channels"
done
done
# ---------------------------------------------------------------- payments
# alice can only route to carol once the bob->carol channel has been announced and
# reached her graph. Channels are confirmed by now, but gossip is not instant --
# --trickledelay alone is 5s. Paying before this lands fails with "no route".
log "Waiting for the channel graph to reach alice"
for i in $(seq 1 90); do
edges=$(lncli alice describegraph | grep -c '"channel_id"' || true)
(( ${edges:-0} >= 2 )) && { info "alice sees ${edges} channels in her graph"; break; }
sleep 1
(( i == 90 )) && die "channel graph never propagated to alice"
done
# Fixed amounts. alice -> carol routes through bob, generating forwarding history.
log "Sending payments (alice -> carol, routed via bob)"
for amt in 10000 25000 50000 75000 100000; do
inv=$(lncli carol addinvoice --amt="$amt" --memo="seed payment ${amt} sats" \
| json_first payment_request)
if lncli alice payinvoice --force --pay_req="$inv" >/dev/null 2>&1; then
info "alice -> carol ${amt} sats (routed)"
else
info "alice -> carol ${amt} sats FAILED (route not ready?)"
fi
done
log "Sending direct payments (alice -> bob)"
for amt in 5000 15000; do
inv=$(lncli bob addinvoice --amt="$amt" --memo="direct payment ${amt} sats" \
| json_first payment_request)
lncli alice payinvoice --force --pay_req="$inv" >/dev/null 2>&1 \
&& info "alice -> bob ${amt} sats" \
|| info "alice -> bob ${amt} sats FAILED"
done
# Unsettled invoices, so the invoice list shows more than one state.
log "Creating open (unpaid) invoices on carol"
for amt in 20000 40000; do
lncli carol addinvoice --amt="$amt" --memo="open invoice ${amt} sats" >/dev/null
info "carol open invoice ${amt} sats"
done
# ---------------------------------------------------------------- core lightning
# A Core Lightning node with one active channel, so RTL's CLN screens have data.
# An active (peer_connected) channel is what exercises the connection-status column.
log "Waiting for Core Lightning node"
wait_for "cln" 120 clncli getinfo
log "Funding Core Lightning (${FUND_SATS} sats)"
CLN_ADDR=$(clncli newaddr | json_first bech32)
[ -n "$CLN_ADDR" ] || die "could not get address for cln"
bcli -rpcwallet=rtldev sendtoaddress "$CLN_ADDR" "$BTC_AMOUNT" >/dev/null
info "cln <- $BTC_AMOUNT BTC ($CLN_ADDR)"
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm cln funding"
log "Waiting for cln confirmed on-chain balance"
for i in $(seq 1 60); do
clncli listfunds | grep -q '"status": "confirmed"' && { info "cln funds confirmed"; break; }
sleep 1
(( i == 60 )) && die "cln never saw confirmed funds"
done
log "Opening channel cln -> alice"
clncli connect "${ALICE_PUB}@alice:9735" >/dev/null 2>&1 || info "cln->alice already connected"
clncli fundchannel "$ALICE_PUB" "$CH_CLN_ALICE" >/dev/null
info "cln -> alice ${CH_CLN_ALICE} sats"
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm the cln channel"
log "Waiting for the cln channel to become active"
for i in $(seq 1 90); do
if clncli listpeerchannels | grep -o '"state": "[A-Z_]*"' | grep -q "CHANNELD_NORMAL"; then
info "cln channel is CHANNELD_NORMAL"; break
fi
sleep 1
(( i == 90 )) && die "cln channel never reached CHANNELD_NORMAL"
done
# ---------------------------------------------------------------- eclair
# An Eclair node with one active channel to bob plus a couple of settled and
# open invoices, so RTL's Eclair screens have data. Eclair's on-chain wallet is
# the dedicated "eclair" bitcoind wallet (created by eclair-wallet-init), but
# funding still goes through eclair's own API so its balances update.
log "Waiting for Eclair node"
wait_for "eclair" 180 ecli getinfo
log "Funding Eclair (${FUND_SATS} sats)"
ECL_ADDR=$(ecli getnewaddress | tr -d '"')
[ -n "$ECL_ADDR" ] || die "could not get address for eclair"
bcli -rpcwallet=rtldev sendtoaddress "$ECL_ADDR" "$BTC_AMOUNT" >/dev/null
info "eclair <- $BTC_AMOUNT BTC ($ECL_ADDR)"
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm eclair funding"
log "Waiting for eclair confirmed on-chain balance"
for i in $(seq 1 60); do
ecli onchainbalance | grep -q '"confirmed": *[1-9]' && { info "eclair funds confirmed"; break; }
sleep 1
(( i == 60 )) && die "eclair never saw confirmed funds"
done
log "Opening channel eclair -> bob"
ecli connect --uri="${BOB_PUB}@bob:9735" >/dev/null 2>&1 || info "eclair->bob already connected"
ecli open --nodeId="$BOB_PUB" --fundingSatoshis="$CH_ECL_BOB" --pushMsat=$(( PUSH_SATS * 1000 )) >/dev/null
info "eclair -> bob ${CH_ECL_BOB} sats (push ${PUSH_SATS})"
# 'open' returns before eclair broadcasts the funding tx; mining too early would
# confirm nothing and leave the channel waiting forever.
for i in $(seq 1 30); do
bcli getrawmempool | grep -q '"' && { info "funding tx in mempool"; break; }
sleep 1
(( i == 30 )) && die "eclair funding tx never reached the mempool"
done
bcli -rpcwallet=rtldev generatetoaddress "$ECL_MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $ECL_MINE_CONFIRM blocks to confirm the eclair channel"
log "Waiting for the eclair channel to become active"
for i in $(seq 1 90); do
if ecli channels | grep -q '"state" *: *"NORMAL"'; then
info "eclair channel is NORMAL"; break
fi
sleep 1
(( i == 90 )) && die "eclair channel never reached NORMAL"
done
# Direct payments over the eclair->bob channel; no routing, so no gossip wait.
# payinvoice is asynchronous -- confirm settlement on bob's side.
log "Sending direct payments (eclair -> bob)"
for amt in 8000 18000; do
inv_out=$(lncli bob addinvoice --amt="$amt" --memo="eclair payment ${amt} sats")
inv=$(echo "$inv_out" | json_first payment_request)
rhash=$(echo "$inv_out" | json_first r_hash)
ecli payinvoice --invoice="$inv" >/dev/null 2>&1
paid=""
for i in $(seq 1 30); do
if lncli bob lookupinvoice "$rhash" | grep -q '"state": *"SETTLED"'; then
paid=1; break
fi
sleep 1
done
[ -n "$paid" ] && info "eclair -> bob ${amt} sats" \
|| info "eclair -> bob ${amt} sats FAILED (never settled)"
done
# An unpaid invoice, so the eclair invoice list shows more than one state.
log "Creating an open (unpaid) invoice on eclair"
ecli createinvoice --amountMsat=$(( 30000 * 1000 )) --description="open invoice 30000 sats" >/dev/null
info "eclair open invoice 30000 sats"
# ---------------------------------------------------------------- summary
log "Seed complete"
for n in "${NODES[@]}"; do
chans=$(lncli "$n" listchannels | grep -c '"active": *true' || true)
bal=$(lncli "$n" walletbalance | json_first confirmed_balance)
printf ' %-6s channels: %-3s on-chain: %s sats\n' "$n" "${chans:-0}" "${bal:-0}"
done
cln_chans=$(clncli listpeerchannels | grep -o '"state": "[A-Z_]*"' | grep -c "CHANNELD_NORMAL" || true)
printf ' %-6s channels: %-3s (Core Lightning)\n' "cln" "${cln_chans:-0}"
ecl_chans=$(ecli channels | grep -o '"state" *: *"NORMAL"' | grep -c NORMAL || true)
printf ' %-6s channels: %-3s (Eclair)\n' "eclair" "${ecl_chans:-0}"
# '|| echo 0' would be wrong here: grep -c already prints 0 when it finds nothing
# and then exits 1, so the echo would append a second line.
fwds=$(lncli bob fwdinghistory | grep -c '"chan_id_in"' || true)
printf ' bob forwarded %s payment(s)\n' "${fwds:-0}"
printf '\n RTL: http://localhost:%s (password: %s)\n\n' "${RTL_PORT:-3000}" "${RTL_PASSWORD:-password}"

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(()=>{"use strict";var e,v={},m={};function r(e){var o=m[e];if(void 0!==o)return o.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(o,t,i,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,i,f]=e[n],s=!0,u=0;u<t.length;u++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[u]))?t.splice(u--,1):(s=!1,f<a&&(a=f));if(s){e.splice(n--,1);var d=i();void 0!==d&&(o=d)}}return o}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,i,f]},r.d=(e,o)=>{for(var t in o)r.o(o,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:o[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((o,t)=>(r.f[t](e,o),o),[])),r.u=e=>e+"."+{17:"da5e0b6abb96d103",190:"0e6572086349bd7c",193:"5eec0042e2c6f1a7",853:"e46ed60b577aec33"}[e]+".js",r.miniCssF=e=>{},r.o=(e,o)=>Object.prototype.hasOwnProperty.call(e,o),(()=>{var e={},o="RTLApp:";r.l=(t,i,f,n)=>{if(e[t])e[t].push(i);else{var a,s;if(void 0!==f)for(var u=document.getElementsByTagName("script"),d=0;d<u.length;d++){var l=u[d];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==o+f){a=l;break}}a||(s=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",o+f),a.src=r.tu(t)),e[t]=[i];var c=(h,b)=>{a.onerror=a.onload=null,clearTimeout(p);var g=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),g&&g.forEach(_=>_(b)),h)return h(b)},p=setTimeout(c.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=c.bind(null,a.onerror),a.onload=c.bind(null,a.onload),s&&document.head.appendChild(a)}}})(),(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:o=>o},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={121:0};r.f.j=(i,f)=>{var n=r.o(e,i)?e[i]:void 0;if(0!==n)if(n)f.push(n[2]);else if(121!=i){var a=new Promise((l,c)=>n=e[i]=[l,c]);f.push(n[2]=a);var s=r.p+r.u(i),u=new Error;r.l(s,l=>{if(r.o(e,i)&&(0!==(n=e[i])&&(e[i]=void 0),n)){var c=l&&("load"===l.type?"missing":l.type),p=l&&l.target&&l.target.src;u.message="Loading chunk "+i+" failed.\n("+c+": "+p+")",u.name="ChunkLoadError",u.type=c,u.request=p,n[1](u)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var o=(i,f)=>{var u,d,[n,a,s]=f,l=0;if(n.some(p=>0!==e[p])){for(u in a)r.o(a,u)&&(r.m[u]=a[u]);if(s)var c=s(r)}for(i&&i(f);l<n.length;l++)r.o(e,d=n[l])&&e[d]&&e[d][0](),e[d]=0;return r.O(c)},t=self.webpackChunkRTLApp=self.webpackChunkRTLApp||[];t.forEach(o.bind(null,0)),t.push=o.bind(null,t.push.bind(t))})()})();

View file

@ -1 +0,0 @@
(()=>{"use strict";var e,v={},m={};function r(e){var f=m[e];if(void 0!==f)return f.exports;var t=m[e]={id:e,loaded:!1,exports:{}};return v[e].call(t.exports,t,t.exports,r),t.loaded=!0,t.exports}r.m=v,e=[],r.O=(f,t,i,d)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,i,d]=e[n],c=!0,o=0;o<t.length;o++)(!1&d||a>=d)&&Object.keys(r.O).every(b=>r.O[b](t[o]))?t.splice(o--,1):(c=!1,d<a&&(a=d));if(c){e.splice(n--,1);var u=i();void 0!==u&&(f=u)}}return f}d=d||0;for(var n=e.length;n>0&&e[n-1][2]>d;n--)e[n]=e[n-1];e[n]=[t,i,d]},r.d=(e,f)=>{for(var t in f)r.o(f,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:f[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((f,t)=>(r.f[t](e,f),f),[])),r.u=e=>e+"."+{17:"6fa7154eb6e447e2",190:"03f035c34a56c8be",193:"0e1a81316bbc29da",853:"50b06a24091d386f"}[e]+".js",r.miniCssF=e=>{},r.o=(e,f)=>Object.prototype.hasOwnProperty.call(e,f),(()=>{var e={},f="RTLApp:";r.l=(t,i,d,n)=>{if(e[t])e[t].push(i);else{var a,c;if(void 0!==d)for(var o=document.getElementsByTagName("script"),u=0;u<o.length;u++){var l=o[u];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==f+d){a=l;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",f+d),a.src=r.tu(t)),e[t]=[i];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var h=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),h&&h.forEach(y=>y(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:f=>f},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={121:0};r.f.j=(i,d)=>{var n=r.o(e,i)?e[i]:void 0;if(0!==n)if(n)d.push(n[2]);else if(121!=i){var a=new Promise((l,s)=>n=e[i]=[l,s]);d.push(n[2]=a);var c=r.p+r.u(i),o=new Error;r.l(c,l=>{if(r.o(e,i)&&(0!==(n=e[i])&&(e[i]=void 0),n)){var s=l&&("load"===l.type?"missing":l.type),p=l&&l.target&&l.target.src;o.message="Loading chunk "+i+" failed.\n("+s+": "+p+")",o.name="ChunkLoadError",o.type=s,o.request=p,n[1](o)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var f=(i,d)=>{var o,u,[n,a,c]=d,l=0;if(n.some(p=>0!==e[p])){for(o in a)r.o(a,o)&&(r.m[o]=a[o]);if(c)var s=c(r)}for(i&&i(d);l<n.length;l++)r.o(e,u=n[l])&&e[u]&&e[u][0](),e[u]=0;return r.O(s)},t=self.webpackChunkRTLApp=self.webpackChunkRTLApp||[];t.forEach(f.bind(null,0)),t.push=f.bind(null,t.push.bind(t))})()})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16894
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "rtl",
"version": "0.15.4-beta",
"version": "0.15.10-beta",
"license": "MIT",
"type": "module",
"scripts": {
@ -11,90 +11,97 @@
"watchfrontenddev": "ng build --configuration development --optimization false --watch",
"buildfrontendtest": "ng test --watch=false && ng build",
"buildfrontend": "ng build --configuration production",
"buildbackend": "tsc --project ./server/tsconfig.server.json",
"watchbackend": "tsc --project ./server/tsconfig.server.json --watch",
"buildbackend": "npx tsc --project ./server/tsconfig.server.json",
"watchbackend": "npx tsc --project ./server/tsconfig.server.json --watch",
"server": "set NODE_ENV=development&&nodemon --watch backend --watch server ./rtl.js",
"serverUbuntu": "NODE_ENV=development nodemon --watch backend --watch server ./rtl.js",
"testdev": "ng test --watch=true --code-coverage",
"test": "ng test --watch=false --browsers=ChromeHeadless",
"testbackend": "node --test test/backend/*.test.mjs",
"test": "npm run buildbackend && npm run testbackend && ng test --watch=false --browsers=ChromeHeadless",
"lint": "eslint"
},
"private": true,
"dependencies": {
"@ngrx/effects": "^17.2.0",
"@ngrx/store": "^17.2.0",
"@swimlane/ngx-charts": "^20.5.0",
"angular-user-idle": "^4.0.0",
"atob": "^2.1.2",
"cookie-parser": "^1.4.6",
"crypto-browserify": "^3.12.0",
"csurf": "^1.11.0",
"express": "^4.19.2",
"express-session": "^1.18.0",
"hocon-parser": "^1.0.1",
"ini": "^4.1.3",
"jsonwebtoken": "^9.0.2",
"ng-qrcode": "^18.0.0",
"ngx-perfect-scrollbar-next": "^10.1.1",
"otplib": "^12.0.1",
"pdfmake": "^0.2.10",
"process": "^0.11.10",
"request": "^2.88.2",
"request-promise": "^4.2.6",
"rxjs": "^7.8.1",
"sha256": "^0.2.0",
"socket.io-client": "^4.7.5",
"stream-browserify": "^3.0.0",
"tslib": "^2.6.2",
"vm-browserify": "^1.1.2",
"ws": "^8.17.0",
"zone.js": "^0.14.6"
"@ngrx/effects": "21.0.1",
"@ngrx/store": "21.0.1",
"@swimlane/ngx-charts": "23.1.0",
"angular-user-idle": "4.0.0",
"atob": "2.1.2",
"axios": "1.18.1",
"buffer": "6.0.3",
"cookie-parser": "1.4.7",
"csrf-csrf": "4.0.3",
"express": "5.2.1",
"express-session": "1.18.2",
"hocon-parser": "1.0.1",
"ini": "6.0.0",
"jsonwebtoken": "9.0.3",
"ng-qrcode": "21.0.0",
"ngx-perfect-scrollbar-next": "10.1.1",
"otplib": "12.0.1",
"pdfmake": "0.3.11",
"process": "0.11.10",
"rxjs": "7.8.2",
"sha256": "0.2.0",
"socket.io-client": "4.8.3",
"tslib": "2.8.1",
"ws": "8.21.0",
"zone.js": "0.16.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.2",
"@angular-eslint/builder": "^18.0.1",
"@angular-eslint/eslint-plugin": "^18.0.1",
"@angular-eslint/eslint-plugin-template": "^18.0.1",
"@angular-eslint/schematics": "^18.0.1",
"@angular-eslint/template-parser": "^18.0.1",
"@angular/animations": "^18.0.1",
"@angular/cdk": "^17.0.1",
"@angular/cli": "^18.0.2",
"@angular/common": "^18.0.1",
"@angular/compiler": "^18.0.1",
"@angular/compiler-cli": "^18.0.1",
"@angular/core": "^18.0.1",
"@angular/flex-layout": "^15.0.0-beta.42",
"@angular/forms": "^18.0.1",
"@angular/material": "^17.0.1",
"@angular/platform-browser": "^18.0.1",
"@angular/platform-browser-dynamic": "^18.0.1",
"@angular/router": "^18.0.1",
"@eslint/eslintrc": "^3.1.0",
"@fortawesome/angular-fontawesome": "^0.15.0",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@ngrx/store-devtools": "^17.2.0",
"@types/jasmine": "^5.1.4",
"@types/node": "^20.14.1",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"dotenv": "^16.4.5",
"eslint": "^9.4.0",
"eslint-plugin-deprecation": "^3.0.0",
"jasmine-core": "^5.1.2",
"jasmine-spec-reporter": "^7.0.0",
"karma": "^6.4.3",
"karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.1.0",
"material-icons": "^1.13.12",
"nodemon": "^3.1.3",
"protractor": "^7.0.0",
"roboto-fontface": "^0.10.0",
"ts-node": "^10.9.2",
"typescript": "~5.4.5"
"@angular-devkit/build-angular": "20.3.32",
"@angular-eslint/builder": "20.7.0",
"@angular-eslint/eslint-plugin": "20.7.0",
"@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/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/flex-layout": "15.0.0-beta.42",
"@angular/forms": "20.3.27",
"@angular/material": "20.2.14",
"@angular/platform-browser": "20.3.27",
"@angular/platform-browser-dynamic": "20.3.27",
"@angular/router": "20.3.27",
"@eslint/eslintrc": "3.3.3",
"@fortawesome/angular-fontawesome": "4.0.0",
"@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.1.0",
"@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",
"dotenv": "17.2.3",
"eslint": "9.39.5",
"eslint-plugin-deprecation": "3.0.0",
"jasmine-core": "5.13.0",
"jasmine-spec-reporter": "7.0.0",
"karma": "6.4.4",
"karma-chrome-launcher": "3.2.0",
"karma-coverage": "2.2.1",
"karma-jasmine": "5.1.0",
"karma-jasmine-html-reporter": "2.1.0",
"material-icons": "1.13.14",
"nodemon": "3.1.14",
"roboto-fontface": "0.10.0",
"ts-node": "10.9.2",
"typescript": "5.8.3"
},
"overrides": {
"chalk": "4.1.0",
"strip-ansi": "6.0.1",
"color-convert": "2.0.1",
"color-name": "1.1.4",
"is-core-module": "2.13.0",
"error-ex": "1.3.2",
"has-ansi": "2.1.1"
}
}

View file

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

Some files were not shown because too many files have changed in this diff Show more