Compare commits

..

No commits in common. "master" and "v0.15.10" have entirely different histories.

8 changed files with 2 additions and 439 deletions

View file

@ -33,23 +33,8 @@ 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

View file

@ -50,10 +50,7 @@ frequently do, since the controllers were written in parallel.
`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.
- **`npm run lint` and `npm run test` must both be green before a PR.**
- 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.
@ -94,17 +91,6 @@ Eclair, wired to RTL — for end-to-end testing across all three implementations
`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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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