mirror of
https://github.com/Ride-The-Lightning/RTL.git
synced 2026-08-13 12:33:07 +02:00
Compare commits
102 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e2554ca4 | ||
|
|
a005b687a7 | ||
|
|
f48a647272 | ||
|
|
9cc86b2d4c | ||
|
|
500bd31be5 | ||
|
|
a4246c4fe6 | ||
|
|
0a2c591177 | ||
|
|
a11179085b | ||
|
|
18a40ed183 | ||
|
|
df52b9b77f | ||
|
|
bd0f515a6e | ||
|
|
40bd25d921 | ||
|
|
fa65568914 | ||
|
|
fceb59bfed | ||
|
|
48b71d49e3 | ||
|
|
3082d9a1af | ||
|
|
c5dc49711c | ||
|
|
4276a03463 | ||
|
|
030592ac23 | ||
|
|
0af050a6b2 | ||
|
|
e617fbb561 | ||
|
|
6e61d1b759 | ||
|
|
89b6313a5b | ||
|
|
19aa332d61 | ||
|
|
5d1f55a9c6 | ||
|
|
1efaa24d72 | ||
|
|
9a2c702b4c | ||
|
|
a8baba12bb | ||
|
|
6e48241d85 | ||
|
|
890db72ab6 | ||
|
|
95f064ae31 | ||
|
|
bb2a228662 | ||
|
|
b47e32c88c | ||
|
|
f75ec24844 | ||
|
|
09494dcfc0 | ||
|
|
da74a84e7e | ||
|
|
c1a46b1cbd | ||
|
|
9a799ea314 | ||
|
|
54359f2ceb | ||
|
|
6e55059fe2 | ||
|
|
c2b8670099 | ||
|
|
e0fce065d5 | ||
|
|
e11899a051 | ||
|
|
bd74132265 | ||
|
|
a09eb7d4c5 | ||
|
|
fbd336a89b | ||
|
|
75dba90fae | ||
|
|
f93bc7b1d8 | ||
|
|
86d10df369 | ||
|
|
eed6b8aca2 | ||
|
|
522b7e307d | ||
|
|
5690367d07 | ||
|
|
46c710ca7a | ||
|
|
1782ae2b20 | ||
|
|
343338cd4b | ||
|
|
30712392e6 | ||
|
|
2a9aee2597 | ||
|
|
f518488ecb | ||
|
|
cbf7a14c95 | ||
|
|
9c3cd983b4 | ||
|
|
99cb60d2a7 | ||
|
|
c15fa04986 | ||
|
|
b790dc7abf | ||
|
|
80af65d7fe | ||
|
|
512aea96e5 | ||
|
|
5659ba250b | ||
|
|
16d48417d8 | ||
|
|
42cb3c76e8 | ||
|
|
266167cf53 | ||
|
|
0057bee53d | ||
|
|
4613b889bf | ||
|
|
7d32bd68e9 | ||
|
|
7d676dc940 | ||
|
|
3ae6d65ca2 | ||
|
|
1a6cb0b0a8 | ||
|
|
df48de1140 | ||
|
|
6a01e96c98 | ||
|
|
4e2b7ca978 | ||
|
|
da25d16fb4 | ||
|
|
715afa9043 | ||
|
|
f2314fcacf | ||
|
|
69888dd7b8 | ||
|
|
d8e6bb1b68 | ||
|
|
e8d5685948 | ||
|
|
d038e15bd7 | ||
|
|
bdf9c832dd | ||
|
|
4e08fd7cba | ||
|
|
2f6cdb8fe0 | ||
|
|
f3f21c5949 | ||
|
|
08326f1383 | ||
|
|
e9e33da14d | ||
|
|
bbeb2c3b17 | ||
|
|
10fc3368e9 | ||
|
|
d49124c052 | ||
|
|
0c90e454e8 | ||
|
|
393262fa32 | ||
|
|
b67867af44 | ||
|
|
3f4236b4f2 | ||
|
|
c0c8d77691 | ||
|
|
84d92ff6f6 | ||
|
|
f1ed21915a | ||
|
|
c27f4aff45 |
499 changed files with 15307 additions and 12245 deletions
98
.claude/skills/rtl-docker-fixture/SKILL.md
Normal file
98
.claude/skills/rtl-docker-fixture/SKILL.md
Normal 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.
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
4
.github/README.md
vendored
4
.github/README.md
vendored
|
|
@ -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>
|
||||
[](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)
|
||||
|
|
@ -124,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:
|
||||
|
||||
|
|
|
|||
6
.github/docs/Application_configurations.md
vendored
6
.github/docs/Application_configurations.md
vendored
|
|
@ -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 />
|
||||
|
|
|
|||
86
.github/scripts/export_traffic.py
vendored
Normal file
86
.github/scripts/export_traffic.py
vendored
Normal 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()
|
||||
28
.github/workflows/checks.yml
vendored
28
.github/workflows/checks.yml
vendored
|
|
@ -15,6 +15,14 @@ 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@v4
|
||||
|
|
@ -22,14 +30,14 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache node_modules
|
||||
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,6 +47,14 @@ 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@v4
|
||||
|
|
@ -46,14 +62,14 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache node_modules
|
||||
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'
|
||||
|
|
@ -75,14 +91,14 @@ jobs:
|
|||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Cache node_modules
|
||||
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'
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
.github/workflows/rtlreviewbot.yml
vendored
Normal file
31
.github/workflows/rtlreviewbot.yml
vendored
Normal 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
39
.github/workflows/traffic-exporter.yml
vendored
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -64,3 +64,4 @@ RTL-Config-Signet.json
|
|||
RTL-Config-Testnet.json
|
||||
RTL-Config-All.json
|
||||
.aider*
|
||||
.continue
|
||||
|
|
|
|||
122
CLAUDE.md
Normal file
122
CLAUDE.md
Normal 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.
|
||||
|
|
@ -77,3 +77,15 @@ Contributions via code is the most sought after contribution and something we en
|
|||
* 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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
ARG BASE_DISTRO="node:20-alpine"
|
||||
ARG BASE_DISTRO="node:22-alpine"
|
||||
|
||||
FROM --platform=${BUILDPLATFORM} ${BASE_DISTRO} AS builder
|
||||
|
||||
|
|
|
|||
2
LICENSE
2
LICENSE
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,9 +14,18 @@ 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 });
|
||||
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.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');
|
||||
});
|
||||
common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,9 +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;
|
||||
// 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..' });
|
||||
|
|
@ -15,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);
|
||||
|
|
@ -87,16 +103,34 @@ export const getAlias = (selNode, peer, id) => {
|
|||
peer.alias = '';
|
||||
return Promise.resolve(peer);
|
||||
}
|
||||
if (aliasCache.has(peerId)) {
|
||||
peer.alias = aliasCache.get(peerId);
|
||||
const cached = aliasCache.get(peerId);
|
||||
if (cached && (Date.now() - cached.ts) < ALIAS_CACHE_TTL) {
|
||||
peer.alias = cached.alias;
|
||||
return Promise.resolve(peer);
|
||||
}
|
||||
options.url = selNode.settings.lnServerUrl + '/v1/listnodes';
|
||||
options.body = { id: peerId };
|
||||
return request.post(options).then((body) => {
|
||||
// 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 });
|
||||
const alias = body.nodes?.[0]?.alias || peerId.substring(0, 20);
|
||||
aliasCache.set(peerId, alias);
|
||||
// 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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -609,13 +657,28 @@ export class CommonService {
|
|||
return JSON.parse(dataStr);
|
||||
};
|
||||
this.runWithConcurrencyLimit = (tasks, limit, done) => {
|
||||
const results = new Array(tasks.length);
|
||||
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) {
|
||||
done(results); // all tasks are finished
|
||||
finish(); // all tasks are finished
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -637,7 +700,10 @@ export class CommonService {
|
|||
runNext();
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < limit && i < tasks.length; i++) {
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 || ''), '', () => { });
|
||||
|
|
|
|||
|
|
@ -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
96
backend/utils/request.js
Normal 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;
|
||||
44
docker/.env
44
docker/.env
|
|
@ -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
|
||||
|
|
|
|||
328
docker/README.md
328
docker/README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
20
docker/bin/e-cli
Executable 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}" \
|
||||
"$@"
|
||||
|
|
@ -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
39
docker/bin/sso-url
Executable 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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
daemon=0
|
||||
printtoconsole=1
|
||||
31
docker/cln/create-rune.sh
Executable file
31
docker/cln/create-rune.sh
Executable 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}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
[Application Options]
|
||||
debuglevel=info
|
||||
46
docker/nginx/rtl-sso.conf
Normal file
46
docker/nginx/rtl-sso.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
103
docker/rtl/RTL-Config.regtest.json
Normal file
103
docker/rtl/RTL-Config.regtest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
30
docker/rtl/RTL-Config.sso.json
Normal file
30
docker/rtl/RTL-Config.sso.json
Normal 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
367
docker/scripts/seed.sh
Executable 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
110
docker/scripts/verify-sso.sh
Executable 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
1
frontend/17.da5e0b6abb96d103.js
Normal file
1
frontend/17.da5e0b6abb96d103.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend/190.0e6572086349bd7c.js
Normal file
1
frontend/190.0e6572086349bd7c.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend/193.5eec0042e2c6f1a7.js
Normal file
1
frontend/193.5eec0042e2c6f1a7.js
Normal file
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
1
frontend/853.e46ed60b577aec33.js
Normal file
1
frontend/853.e46ed60b577aec33.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend/main.87c60b2108713046.js
Normal file
1
frontend/main.87c60b2108713046.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/polyfills.17d9756e1f282744.js
Normal file
1
frontend/polyfills.17d9756e1f282744.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
frontend/runtime.785eb15c327b373a.js
Normal file
1
frontend/runtime.785eb15c327b373a.js
Normal 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))})()})();
|
||||
|
|
@ -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,o)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,i,o]=e[n],s=!0,l=0;l<t.length;l++)(!1&o||a>=o)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(s=!1,o<a&&(a=o));if(s){e.splice(n--,1);var d=i();void 0!==d&&(f=d)}}return f}o=o||0;for(var n=e.length;n>0&&e[n-1][2]>o;n--)e[n]=e[n-1];e[n]=[t,i,o]},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:"398f8f87a3703825",853:"c952c78c4b4f76e6"}[e]+".js",r.miniCssF=e=>{},r.o=(e,f)=>Object.prototype.hasOwnProperty.call(e,f),(()=>{var e={},f="RTLApp:";r.l=(t,i,o,n)=>{if(e[t])e[t].push(i);else{var a,s;if(void 0!==o)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==f+o){a=u;break}}a||(s=!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+o),a.src=r.tu(t)),e[t]=[i];var c=(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(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)}}})(),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,o)=>{var n=r.o(e,i)?e[i]:void 0;if(0!==n)if(n)o.push(n[2]);else if(121!=i){var a=new Promise((u,c)=>n=e[i]=[u,c]);o.push(n[2]=a);var s=r.p+r.u(i),l=new Error;r.l(s,u=>{if(r.o(e,i)&&(0!==(n=e[i])&&(e[i]=void 0),n)){var c=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+i+" failed.\n("+c+": "+p+")",l.name="ChunkLoadError",l.type=c,l.request=p,n[1](l)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var f=(i,o)=>{var l,d,[n,a,s]=o,u=0;if(n.some(p=>0!==e[p])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(s)var c=s(r)}for(i&&i(o);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[d]=0;return r.O(c)},t=self.webpackChunkRTLApp=self.webpackChunkRTLApp||[];t.forEach(f.bind(null,0)),t.push=f.bind(null,t.push.bind(t))})()})();
|
||||
1
frontend/styles.5b012787da2659f9.css
Normal file
1
frontend/styles.5b012787da2659f9.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
17799
package-lock.json
generated
17799
package-lock.json
generated
File diff suppressed because it is too large
Load diff
122
package.json
122
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "rtl",
|
||||
"version": "0.15.6-beta",
|
||||
"version": "0.15.10-beta",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -11,91 +11,89 @@
|
|||
"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",
|
||||
"@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",
|
||||
"cookie-parser": "1.4.6",
|
||||
"crypto-browserify": "3.12.0",
|
||||
"csurf": "1.11.0",
|
||||
"express": "4.19.2",
|
||||
"express-session": "1.18.0",
|
||||
"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": "4.1.3",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"ng-qrcode": "18.0.0",
|
||||
"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.2.10",
|
||||
"pdfmake": "0.3.11",
|
||||
"process": "0.11.10",
|
||||
"request": "2.88.2",
|
||||
"request-promise": "4.2.6",
|
||||
"rxjs": "7.8.1",
|
||||
"rxjs": "7.8.2",
|
||||
"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"
|
||||
"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-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": "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",
|
||||
"@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.1.2",
|
||||
"jasmine-core": "5.13.0",
|
||||
"jasmine-spec-reporter": "7.0.0",
|
||||
"karma": "6.4.3",
|
||||
"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.12",
|
||||
"nodemon": "3.1.3",
|
||||
"protractor": "7.0.0",
|
||||
"material-icons": "1.13.14",
|
||||
"nodemon": "3.1.14",
|
||||
"roboto-fontface": "0.10.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.4.5"
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"chalk": "4.1.0",
|
||||
|
|
@ -105,5 +103,5 @@
|
|||
"is-core-module": "2.13.0",
|
||||
"error-ex": "1.3.2",
|
||||
"has-ansi": "2.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
release-notes/Release-notes-0.15.10.md
Normal file
102
release-notes/Release-notes-0.15.10.md
Normal 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.
|
||||
304
release-notes/Release-notes-0.15.9.md
Normal file
304
release-notes/Release-notes-0.15.9.md
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
# Release Notes — 0.15.9
|
||||
|
||||
This document collects the changes that go into the 0.15.9 release. Each PR merged for
|
||||
this release should add its entry under the appropriate section below.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Multi-node: preserve per-node auth when saving application settings**
|
||||
([#1645](https://github.com/Ride-The-Lightning/RTL/pull/1645), supersedes
|
||||
[#1598](https://github.com/Ride-The-Lightning/RTL/pull/1598)).
|
||||
When saving application settings on a multi-node setup, `addSecureData` matched each saved
|
||||
node against the in-memory config by **array position** (`appConfig.nodes[i]`), so once nodes
|
||||
were reordered or one was removed, another node's `macaroonPath`/`runePath` could be grafted
|
||||
onto the wrong node — corrupting its authentication. Matching is now keyed by `node.index`
|
||||
(via a lookup map) in both `addSecureData` and the config-write path, and the persisted
|
||||
`RTL-Config.json` is sanitized of runtime-only fields (the request `options` object and the
|
||||
resolved `runeValue`) so they are never written to disk. Contributed by @CosimoRicciardi in
|
||||
#1598; landed here rebased onto the release branch with a regression test
|
||||
(`test/backend/rtlconf.test.mjs`).
|
||||
|
||||
- **All implementations: fix a page-load error when a channel's alias is undefined**
|
||||
([#1581](https://github.com/Ride-The-Lightning/RTL/pull/1581)).
|
||||
On the home dashboard, channel labels were rendered as `(channel.alias || channel.peer_id).length`
|
||||
(and the `remote_alias`/`shortChannelId` variants). When both the alias and its fallback id were
|
||||
undefined, calling `.length` on `undefined` threw during change detection and errored the page on
|
||||
load. The bindings now fall back to an empty string (`|| ''`, with optional chaining) so a missing
|
||||
alias can no longer break the page.
|
||||
|
||||
- **Multi-node: fix stale auth options blocking a node whose credentials weren't ready at startup**
|
||||
([#1601](https://github.com/Ride-The-Lightning/RTL/pull/1601)).
|
||||
`CommonService.setOptions` short-circuited on `this.nodes[0]` regardless of which node was being
|
||||
processed, so once the first node's auth headers loaded, every subsequent call returned early. A
|
||||
second node whose credential load had failed — e.g. a Core Lightning rune file written
|
||||
asynchronously after RTL starts — was never retried and kept failing with `missing rune!` until
|
||||
RTL was restarted. The cache check is now per-node, so a node that failed to initialize is retried
|
||||
on the next request once its credential is available, while already-loaded nodes are still skipped.
|
||||
|
||||
- **Core Lightning: fix contradictory channel connection status between the list and the
|
||||
detail panel** ([#1625](https://github.com/Ride-The-Lightning/RTL/pull/1625), fixes
|
||||
[#1606](https://github.com/Ride-The-Lightning/RTL/issues/1606)).
|
||||
CLN's `listpeerchannels` reports connection state as `peer_connected`, but the open and
|
||||
pending channel-list columns read the legacy `connected` field, which the backend never
|
||||
populated. The list therefore always rendered "Disconnected" while the detail panel (which
|
||||
reads `peer_connected`) showed the true state. The backend now normalizes
|
||||
`connected = peer_connected` in the `listPeerChannels` response so legacy consumers stay in
|
||||
sync, and the list columns read `peer_connected` directly. Regression tests were added for
|
||||
both channel tables.
|
||||
|
||||
- **Core Lightning: fix the channel View Info modal rendering blank for disconnected channels**
|
||||
([#1625](https://github.com/Ride-The-Lightning/RTL/pull/1625), fixes
|
||||
[#1606](https://github.com/Ride-The-Lightning/RTL/issues/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 showed no value. Because a disconnected channel moves to
|
||||
the pending/inactive table, this is exactly what was seen on "View Info" for a disconnected
|
||||
channel. The pending table now passes `selNode` (matching the open table), and the modal guards
|
||||
the explorer link so a missing `selNode` can no longer blank the dialog. The LND channel
|
||||
information modal had the same unguarded `selNode.settings.blockExplorerUrl` binding (reachable
|
||||
from the active-HTLCs and channel-backup tables, which open it without `selNode`), so the same
|
||||
guard was applied there for parity. Eclair's modal doesn't use `selNode.settings`, so it is
|
||||
unaffected.
|
||||
|
||||
- **All implementations: restore the "items per page" dropdown (and first/last-page buttons)
|
||||
on paginated tables** ([#1626](https://github.com/Ride-The-Lightning/RTL/pull/1626), fixes
|
||||
[#1580](https://github.com/Ride-The-Lightning/RTL/issues/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. Because the two properties have
|
||||
opposite polarity, this inverted the behavior: on desktop the page-size selector was hidden,
|
||||
so users were locked to 10 items per page with no way to raise it — and the first/last-page
|
||||
buttons were dropped everywhere as collateral. Reverting the ~44 affected paginators back to
|
||||
`[showFirstLastButtons]` restores both behaviors across the LND, Core Lightning, Eclair and
|
||||
shared tables.
|
||||
|
||||
- **Accessibility: add missing form-field labels and remove positive tab indexes**
|
||||
([#1609](https://github.com/Ride-The-Lightning/RTL/pull/1609), fixes
|
||||
[#1566](https://github.com/Ride-The-Lightning/RTL/issues/1566)).
|
||||
Several `mat-select` and datepicker controls across the send, invoice, open/close-channel,
|
||||
bump-fee, public-key and settings forms were rendered without a `mat-label`, so screen readers
|
||||
had no way to announce their purpose (WCAG 1.3.1 / 3.3.2). Descriptive labels were added to
|
||||
the affected controls. The forms also relied on positive `tabindex` values, an anti-pattern
|
||||
(WCAG 2.4.3) that produced an inconsistent keyboard order; these were removed so focus follows
|
||||
natural DOM order across the LND, Core Lightning, Eclair and shared modals.
|
||||
|
||||
- **Bound peer/route alias resolution to stop clnrest "Resource temporarily unavailable" errors**
|
||||
([#1629](https://github.com/Ride-The-Lightning/RTL/pull/1629),
|
||||
fixes [#1501](https://github.com/Ride-The-Lightning/RTL/issues/1501)).
|
||||
RTL resolves peer aliases by calling `listnodes` (CLN) / `graph/node` (LND) once per peer. A
|
||||
prior fix bounded this to 20 concurrent calls (plus a cache) for the CLN channel list, but the
|
||||
Core Lightning **peers list** and **route lookup** — and the LND **peers list** — still fired an
|
||||
unbounded `Promise.all`, one request per peer at once. On Core Lightning nodes with many peers
|
||||
this overwhelms clnrest and fails with `Resource temporarily unavailable (os error 11)`
|
||||
(`EAGAIN`), leaving raw node IDs instead of aliases. All of these paths now use the same 20-way
|
||||
concurrency limit (Eclair already resolves aliases inline from a bulk nodes list, so it is
|
||||
unaffected). The CLN alias lookup was also made self-contained so aliases resolve regardless of
|
||||
which screen is opened first; the limiter now resolves immediately for an empty or non-positive
|
||||
input (which would previously never send a response); and the CLN alias cache gained a 6-hour TTL
|
||||
and a max size so aliases refresh without an RTL restart and the cache can't grow unbounded.
|
||||
|
||||
- **Reports: realign the Scroll Range select with the date picker**
|
||||
([#1637](https://github.com/Ride-The-Lightning/RTL/pull/1637), fixes
|
||||
[#1635](https://github.com/Ride-The-Lightning/RTL/issues/1635)).
|
||||
The a11y fix in #1609 wrapped the Reports page's bare Scroll Range `mat-select` in a
|
||||
`mat-form-field` so it could carry a label, but the new wrapper reserved Material's
|
||||
hint/subscript space below the input (78.8px total vs the date field's 56px) and was
|
||||
top-anchored, leaving the Monthly/Yearly Date picker sitting ~11px lower than the select
|
||||
on every implementation's report screens. The field now uses `subscriptSizing="dynamic"`
|
||||
(no hints are used, so no space is reserved) and centers on the cross axis, restoring the
|
||||
aligned 56px control row from v0.15.8 while keeping the accessibility label. Verified by
|
||||
measuring the rendered layout headlessly against the regtest fixture: both fields now
|
||||
render at identical top/height.
|
||||
|
||||
## Enhancements
|
||||
|
||||
- **Add a Disable Authentication option**
|
||||
([#1582](https://github.com/Ride-The-Lightning/RTL/pull/1582)).
|
||||
A new `disableAuth` config flag (or `DISABLE_AUTH` environment variable) lets RTL run without its
|
||||
login screen — intended for node-platform vendors who put their own authentication layer in front
|
||||
of RTL, not for standalone users. When enabled, RTL issues a session token automatically and
|
||||
disables password updates and 2FA; a configured `APP_PASSWORD` is rejected as incompatible.
|
||||
Backend, frontend, and configuration docs were updated.
|
||||
|
||||
- **LND: show "Blocks till Maturity" by default on the Pending Force Closing list**
|
||||
([#1627](https://github.com/Ride-The-Lightning/RTL/pull/1627), fixes
|
||||
[#1567](https://github.com/Ride-The-Lightning/RTL/issues/1567)).
|
||||
Blocks-till-maturity is critical for a force-closing channel, but it was only visible in the
|
||||
per-channel detail modal. The column and its data binding already existed in the table (and
|
||||
was selectable via column settings); it was simply absent from the default column selection.
|
||||
Added `blocks_til_maturity` to the `pending_force_closing` defaults for both the desktop and
|
||||
mobile (SM) layouts, so it's surfaced on the list out of the box. Users who have already
|
||||
customized this page keep their saved columns and can add it via the column-settings gear.
|
||||
|
||||
## Code Health
|
||||
|
||||
- **LND: remove the deprecated `outgoing_chan_id` from QueryRoutes**
|
||||
([#1591](https://github.com/Ride-The-Lightning/RTL/pull/1591)).
|
||||
LND deprecated the singular `outgoing_chan_id` query parameter on `QueryRoutes` as of
|
||||
v0.20.0 in favor of the plural `outgoing_chan_ids`. The code path was already unreachable
|
||||
in RTL — no caller of the `GetQueryRoutes` action ever populated `outgoingChanId`, so the
|
||||
parameter was never sent — so this drops the unused model field, the effect's conditional
|
||||
URL builder, and the server-side passthrough. The plural `outgoing_chan_ids` used by the
|
||||
send-payment and rebalance flows is unaffected.
|
||||
|
||||
- **LND: migrate `sat_per_byte` to `sat_per_vbyte` in node requests**
|
||||
([#1592](https://github.com/Ride-The-Lightning/RTL/pull/1592)).
|
||||
LND's v0.21.0 release notes deprecate the `sat_per_byte` field, with removal planned in
|
||||
v0.22 across `CloseChannel`, `OpenChannel`, `SendCoins`, `SendMany` and
|
||||
`walletrpc.BumpFee`. LND already interprets the old field as sat/vbyte internally, so
|
||||
this is a pure wire-format rename with no value conversion. The close-channel,
|
||||
open-channel, send-coins and bump-fee request paths (and their matching TypeScript
|
||||
identifiers) now send `sat_per_vbyte`, keeping RTL compatible ahead of the removal.
|
||||
|
||||
- **Batch dependency update resolving all 20 open Dependabot security PRs**
|
||||
([#1633](https://github.com/Ride-The-Lightning/RTL/pull/1633)).
|
||||
Dependabot had 20 open security-alert PRs against `master` (#1583–#1617). Rather than
|
||||
merging them piecemeal (they conflict with each other on `package-lock.json` and target
|
||||
the wrong branch for the release flow), the same bumps were applied in one pass on the
|
||||
release branch: `axios` 1.16.0 and `ws` 8.21.0 (direct), the socket.io server stack
|
||||
(`engine.io`, `engine.io-client`, `socket.io-adapter`, `socket.io-parser`), express's
|
||||
`path-to-regexp`, `follow-redirects`, `lodash`, and the rest of the flagged transitive
|
||||
deps; the Angular framework packages moved 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).
|
||||
In-range fixes Dependabot hadn't re-opened PRs for (`pdfmake` 0.3.11 for its SSRF
|
||||
advisory, `qs`, `uuid`, `tough-cookie`, `cookie`, `ajv`, `bn.js`, `elliptic`) were
|
||||
picked up in the same pass. `npm audit` goes from 85 vulnerabilities (23 production)
|
||||
to 29 (13 production, none high or critical besides the `request` stack); everything
|
||||
remaining requires code changes, not version bumps — the deprecated
|
||||
`request`/`request-promise` stack, `csurf` and the `crypto-browserify` polyfill
|
||||
chain — and is tracked separately. Verified with a clean lint, the full frontend test suite, both production
|
||||
builds, and an end-to-end smoke test of the docker regtest fixture across LND, Core
|
||||
Lightning and Eclair (auth, getinfo, channel lists, and the WebSocket upgrade path).
|
||||
|
||||
- **Replace the deprecated `request`/`request-promise` HTTP stack with axios**
|
||||
([#1638](https://github.com/Ride-The-Lightning/RTL/pull/1638), part of
|
||||
[#1634](https://github.com/Ride-The-Lightning/RTL/issues/1634)).
|
||||
`request` has been deprecated and unmaintained since 2020 and carries an unfixable SSRF
|
||||
advisory plus vulnerable pinned copies of `form-data` (critical), `qs`, `tough-cookie` and
|
||||
`uuid` — 8 of the 13 production `npm audit` findings left after #1633, none fixable by a
|
||||
version bump. All 36 backend files that imported `request-promise` (the LND, Core Lightning
|
||||
and Eclair controllers, Boltz/Loop/RTLConf shared controllers, `common.ts` and the LND
|
||||
websocket client) now go through a small compatibility wrapper (`server/utils/request.ts`)
|
||||
backed by `axios` — already a production dependency, so nothing new is added. The wrapper
|
||||
accepts the existing request-promise options (`qs`, `form` including pre-encoded string
|
||||
bodies, `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`'s status-code and
|
||||
message extraction (including the `ECONNREFUSED` → 503 mapping and Eclair's status-code
|
||||
special case) behaves as before; auth headers are omitted from rejected errors so they
|
||||
cannot leak into logs. Callers without `json: true` still receive the raw text body, and
|
||||
LND's line-delimited `/v2/router/send` stream still surfaces as a string for the existing
|
||||
parser. Production `npm audit` drops from 13 findings (2 critical) to 6 low, all in the
|
||||
`crypto-browserify`/`elliptic` polyfill chain tracked in #1634. Verified end-to-end against
|
||||
the docker regtest fixture: 43 API checks across all three implementations (reads, invoice
|
||||
creation, a routed LND payment over the streaming endpoint, cross-implementation payments
|
||||
from Core Lightning and Eclair, message sign/verify, channel backup to disk, bad-invoice
|
||||
and node-unreachable error mapping) plus a clean lint and both production builds.
|
||||
|
||||
- **Replace the deprecated `csurf` middleware with `csrf-csrf`**
|
||||
([#1643](https://github.com/Ride-The-Lightning/RTL/pull/1643), part of
|
||||
[#1634](https://github.com/Ride-The-Lightning/RTL/issues/1634)).
|
||||
`csurf` has been deprecated since 2022 and pins an old `cookie` release with a known
|
||||
advisory; npm's only offered "fix" is a downgrade. It is now replaced by the maintained
|
||||
`csrf-csrf` (v4), which implements the same double-submit-cookie pattern with an
|
||||
HMAC-signed, session-bound token keyed on RTL's existing boot secret. The frontend
|
||||
contract is unchanged — the token still arrives in the `XSRF-TOKEN` cookie/header and is
|
||||
echoed back as `x-xsrf-token` (all header/body/query token sources csurf accepted are
|
||||
still accepted), the signed token cookie keeps the `_csrf` name (now httpOnly), and the
|
||||
`EBADCSRFTOKEN` error path in `app.ts` applies as before, so no Angular or Quickpay
|
||||
changes were needed. One behavioral fix this surfaced: `app.ts` called `req.csrfToken()`
|
||||
twice (cookie + header) — harmless under csurf, but token-desyncing under csrf-csrf —
|
||||
and now generates the token once per request. Tokens are also now bound to the session,
|
||||
so a token stolen from one session no longer validates in another — a check csurf's
|
||||
cookie mode didn't perform (and the websocket upgrade check in `authCheck.ts` keeps its
|
||||
previous semantics). 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, Core Lightning and Eclair) plus
|
||||
a dedicated CSRF battery — valid-token auth, missing/garbage token → 403,
|
||||
cross-session token replay → 403, token stability, the `XSRF-TOKEN` response header for
|
||||
Quickpay, and the websocket handshake.
|
||||
**Note for third-party scripts / API consumers**: because tokens are session-bound, the
|
||||
session cookie (`connect.sid`) must now be carried alongside the `_csrf` cookie and the
|
||||
token header — a token without its session no longer validates. Handshake against `GET /`
|
||||
(or any non-static route): static-served paths such as `/rtl/` do not mint CSRF cookies.
|
||||
If a token goes stale (destroyed or expired session, or an RTL restart), the 403 response
|
||||
re-mints fresh `XSRF-TOKEN`/`_csrf` cookies, so retrying with the new token succeeds.
|
||||
|
||||
- **Drop the `crypto-browserify` polyfill chain by moving 2FA TOTP to WebCrypto**
|
||||
([#1644](https://github.com/Ride-The-Lightning/RTL/pull/1644), closes
|
||||
[#1634](https://github.com/Ride-The-Lightning/RTL/issues/1634)).
|
||||
The frontend build pulled in the browser polyfills `crypto-browserify`, `stream-browserify`
|
||||
and `vm-browserify` (mapped in via `tsconfig.json` `paths`) solely because `otplib`'s
|
||||
`@otplib/plugin-crypto` requires Node's `crypto`. That chain carried the last remaining
|
||||
production `npm audit` findings — the `elliptic` advisory (GHSA-848j-6mx2-7j84, no fixed
|
||||
release) plus `browserify-sign`/`create-ecdh`. The two-factor-auth settings dialog — the only
|
||||
browser consumer of `otplib` — now uses a small WebCrypto-based 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 and their `tsconfig` path mappings are
|
||||
removed. The backend still verifies login tokens with `otplib`, and the new service is a
|
||||
byte-for-byte match for it (verified against otplib and the RFC 6238 test vectors), so existing
|
||||
authenticator enrollments keep working unchanged. `token.check()` becomes async (WebCrypto's
|
||||
digest API is promise-based); the dialog's verify handler was updated accordingly. **Production
|
||||
`npm audit` now reports zero vulnerabilities** (down from 13, incl. 2 critical, at the start of
|
||||
this dependency-cleanup series). Verified against the docker regtest fixture: enrolling a 2FA
|
||||
secret generated by the new service, confirming the backend's `otplib` accepts a token it
|
||||
produces at login, and rejecting wrong/absent tokens — plus a unit spec covering the RFC 6238
|
||||
vectors, `keyuri` parity, and base32 round-tripping, both API suites, and the full frontend
|
||||
spec suite (204 specs).
|
||||
|
||||
- **Rebuild the compiled CLN channels controller to match its source**
|
||||
([#1631](https://github.com/Ride-The-Lightning/RTL/pull/1631)).
|
||||
The #1606 fix updated `server/controllers/cln/channels.ts` to mirror `peer_connected` onto the
|
||||
legacy `connected` field, but the committed compiled artifact
|
||||
`backend/controllers/cln/channels.js` was never regenerated, so it lagged its source. Rebuilt it
|
||||
so the committed backend output includes the connected-mirror line.
|
||||
|
||||
## Developer Tooling
|
||||
|
||||
- **Link the release notes from the README for discoverability**
|
||||
([#1646](https://github.com/Ride-The-Lightning/RTL/pull/1646)).
|
||||
The `release-notes/` folder was not referenced anywhere — no README link, workflow, or
|
||||
script — so it was effectively undiscoverable. The README's intro navigation now links to
|
||||
it (`[Release Notes](../release-notes)`, alongside the existing docs links), so the
|
||||
per-release notes are reachable from the repo homepage. The folder stays at the repo root
|
||||
(release history is content, not `.github/` repo-meta).
|
||||
|
||||
- **Documented the Dependabot / dependency-update process in CONTRIBUTING.md**
|
||||
([#1636](https://github.com/Ride-The-Lightning/RTL/pull/1636)).
|
||||
Dependabot's security PRs target `master` and are never merged individually — they are
|
||||
resolved in batch dependency-update PRs against the current release branch (as done in
|
||||
#1633). That process was previously undocumented. CONTRIBUTING.md now has a "Handling
|
||||
Dependabot PRs" section covering the full flow: collecting targets (including in-range
|
||||
fixes hidden by exact pins), applying bumps with Angular in lockstep, regenerating the
|
||||
lockfile from scratch, rebuilding and committing the compiled artifacts, verification,
|
||||
and tracking deprecated packages that need code-level replacement in dedicated issues.
|
||||
|
||||
- **Rebuilt the regtest docker fixture**
|
||||
([#1621](https://github.com/Ride-The-Lightning/RTL/pull/1621)).
|
||||
The `docker/` dev setup had been unable to start since February 2021 — a broken `boltz` service
|
||||
(undeclared `BOLTZ_*` variables, a non-existent build context, and undeclared volumes) made
|
||||
Compose reject the whole project, so even `docker compose up -d bitcoind` failed. It was replaced
|
||||
with a working regtest network: `bitcoind` 30.0 + three LND 0.20.0-beta nodes
|
||||
(alice → bob → carol, so RTL's routing/forwarding screens have data) + RTL, all using Polar's
|
||||
multi-arch images (nothing built locally; works on arm64), plus a deterministic `seed.sh`. The
|
||||
Core Lightning node below was later added on top of this fixture.
|
||||
|
||||
- **Added a Core Lightning node to the regtest docker fixture**
|
||||
([#1625](https://github.com/Ride-The-Lightning/RTL/pull/1625)).
|
||||
The `docker/` fixture now runs a `cln` node (official `elementsproject/lightningd` image)
|
||||
alongside the three LND nodes, wired to RTL over clnrest with rune auth, and the seed opens
|
||||
a `cln→alice` channel. This gives RTL's Core Lightning screens a real backend for local
|
||||
development and testing — it was used to verify the CLN channel-connection fix above
|
||||
end-to-end. See `docker/README.md`.
|
||||
|
||||
- **Added an Eclair node to the regtest docker fixture**
|
||||
([#1632](https://github.com/Ride-The-Lightning/RTL/pull/1632)).
|
||||
The `docker/` fixture now runs an `eclair` node alongside the LND and Core Lightning nodes,
|
||||
completing backend coverage of all three implementations RTL supports. RTL talks to its HTTP
|
||||
API with basic auth (`lnApiPassword`), and the seed opens an `eclair→bob` channel plus
|
||||
payments and an open invoice so RTL's Eclair screens have data. Polar's multi-arch
|
||||
`polarlightning/eclair` image is used because the official `acinq/eclair` image is amd64-only
|
||||
and its versioned tags are years stale. Since Eclair drives a bitcoind wallet rather than its
|
||||
own, an init container creates a dedicated `eclair` wallet before the node starts — otherwise
|
||||
it would attach to the fixture's mining wallet. A `bin/e-cli` helper wraps `eclair-cli`.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import request from 'request-promise';
|
||||
import request from '../../utils/request.js';
|
||||
import { Logger, LoggerService } from '../../utils/logger.js';
|
||||
import { Common, CommonService } from '../../utils/common.js';
|
||||
import { getAlias } from './network.js';
|
||||
|
|
@ -14,9 +14,18 @@ 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 });
|
||||
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.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');
|
||||
});
|
||||
common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue