From 72f0b4242fae419217b3b6d727b9fb24dbb79b28 Mon Sep 17 00:00:00 2001 From: Sem <931684+sembrestels@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:50:46 +0200 Subject: [PATCH] feat: split Lightning into official LND/RTL services with verified TLS Use digest-pinned LND 0.21.4 and RTL 0.15.15 images with Node 22.23.4. Retain the verified TLS patch, read-only credential sharing, staged wallet migration guard and service-aware setup wizard. Validate both images, 397 backend tests, SDK package build and isolated unfunded fresh/encrypted/staged regtests. --- .github/workflows/node-runtime-test.yml | 21 + .travis.yml | 22 - MIGRATION.md | 41 + README.md | 167 +-- buildLnd/Dockerfile | 8 + buildLnd/entrypoint.sh | 48 + buildRtl/Dockerfile | 30 + .../RTL-Config.json | 14 +- buildRtl/entrypoint.sh | 6 + .../patches/rtl-lnd-tls-verification.patch | 1244 +++++++++++++++++ buildRtl/render-config.cjs | 10 + dappnode_package.json | 27 +- docker-compose.yml | 22 +- setup-target.json | 10 - setup-ui.json | 6 - setup-wizard.yml | 20 + setup.schema.json | 17 - src/Dockerfile | 49 - src/config/source_RTL.conf | 21 - src/config/supervisord.conf | 33 - src/scripts/start-lnd.sh | 84 -- src/scripts/start-rtl.sh | 14 - tests/Dockerfile.bitcoin-regtest | 9 + tests/Dockerfile.lnd-legacy | 10 + tests/http-smoke.cjs | 5 + tests/mock-lnd.cjs | 18 + tests/node-security-smoke.cjs | 22 + tests/regtest-smoke.py | 169 +++ tests/rtl-wallet-unlock.cjs | 41 + tests/runtime-smoke.py | 128 ++ tests/tls-runtime.cjs | 40 + 31 files changed, 1952 insertions(+), 404 deletions(-) create mode 100644 .github/workflows/node-runtime-test.yml delete mode 100644 .travis.yml create mode 100644 MIGRATION.md create mode 100644 buildLnd/Dockerfile create mode 100644 buildLnd/entrypoint.sh create mode 100644 buildRtl/Dockerfile rename src/config/source_RTL-Config.json => buildRtl/RTL-Config.json (58%) create mode 100644 buildRtl/entrypoint.sh create mode 100644 buildRtl/patches/rtl-lnd-tls-verification.patch create mode 100644 buildRtl/render-config.cjs delete mode 100644 setup-target.json delete mode 100644 setup-ui.json create mode 100644 setup-wizard.yml delete mode 100644 setup.schema.json delete mode 100644 src/Dockerfile delete mode 100644 src/config/source_RTL.conf delete mode 100644 src/config/supervisord.conf delete mode 100755 src/scripts/start-lnd.sh delete mode 100755 src/scripts/start-rtl.sh create mode 100644 tests/Dockerfile.bitcoin-regtest create mode 100644 tests/Dockerfile.lnd-legacy create mode 100644 tests/http-smoke.cjs create mode 100644 tests/mock-lnd.cjs create mode 100644 tests/node-security-smoke.cjs create mode 100644 tests/regtest-smoke.py create mode 100644 tests/rtl-wallet-unlock.cjs create mode 100644 tests/runtime-smoke.py create mode 100644 tests/tls-runtime.cjs diff --git a/.github/workflows/node-runtime-test.yml b/.github/workflows/node-runtime-test.yml new file mode 100644 index 0000000..cda220c --- /dev/null +++ b/.github/workflows/node-runtime-test.yml @@ -0,0 +1,21 @@ +name: Split Lightning runtime test +on: [pull_request, workflow_dispatch] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker build -t codex/lightning-lnd:split-20261023 buildLnd + - run: docker build -t codex/lightning-rtl:split-20261023 buildRtl + - run: python3 tests/runtime-smoke.py --output /tmp/lightning-runtime-private + - run: docker build -f tests/Dockerfile.bitcoin-regtest -t codex/bitcoin-regtest:31.1 tests + - run: docker build -f tests/Dockerfile.bitcoin-regtest --build-arg BITCOIN_VERSION=25.2 --build-arg BITCOIN_SHA256=8d8c387e597e0edfc256f0bbace1dac3ad1ebf4a3c06da3e2975fda333817dea -t codex/bitcoin-regtest:25.2 tests + - run: docker build -f tests/Dockerfile.lnd-legacy -t codex/lnd-legacy-regtest:0.14.1 tests + - run: docker build -f tests/Dockerfile.lnd-legacy --build-arg LND_VERSION=v0.15.5-beta --build-arg LND_SHA256=fcdd621a068ab71d0476a7a9ff77fa71f68d230f8bbebe573a70a265ffde4d80 -t codex/lnd-legacy-regtest:0.15.5 tests + - run: python3 tests/regtest-smoke.py --fresh --output /tmp/lightning-fresh-private + - run: python3 tests/regtest-smoke.py --fresh --encrypted-tls --output /tmp/lightning-encrypted-private + - run: python3 tests/regtest-smoke.py --bitcoin-image codex/bitcoin-regtest:25.2 --bridge-image codex/lnd-legacy-regtest:0.15.5 --output /tmp/lightning-staged-private + - uses: actions/setup-node@v4 + with: + node-version: 22.23.4 + - run: npx --yes @dappnode/dappnodesdk@0.3.55 build --skip_save --skip_upload --arch linux/amd64 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 46ba286..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: node_js -dist: xenial -sudo: required - -# ######################## -# How to trigger a release -# -# Push a tag to origin: -# - "release" (= patch) -# - "release/patch" -# - "release/minor" -# - "release/major" -# ######################## - -jobs: - include: - - script: docker-compose build - if: tag !~ ^release - - stage: deploy - node_js: lts/* - if: tag =~ ^release - script: npx @dappnode/dappnodesdk publish --dappnode_team_preset diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..df03775 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,41 @@ +# Lightning application security update + +This patch upgrades RTL from 0.12.1 to 0.15.15 and LND from 0.14.1-beta to 0.21.4-beta. It retains patched Node 22.23.4, refreshes system trust, pins the RTL source commit and verifies the LND release archive checksum. Set a non-default `APP_PASSWORD` before starting RTL. The JSON template now uses current lowercase authentication/settings keys and follows `NETWORK` for its macaroon path. + +## Existing wallets + +**The direct LND 0.14.1 → 0.21.4 jump fails in the isolated regression test** with `top-level bucket "chan-id-bucket" already exists`. Do not install the new image over an old wallet and assume startup will migrate it. The wrapper refuses an existing unmarked wallet unless `LND_MIGRATION_CONFIRMED=true` is set. Fresh installations receive a marker and do not need that setting. + +The tested route is **0.14.1-beta → 0.15.5-beta → 0.21.4-beta**. It preserves an unfunded synthetic regtest wallet's identity, opens its databases, creates a new address and invoice, and retains identity after restart/unlock. The intermediate release is a temporary offline migration tool, not the target production runtime. It was tested against Bitcoin Core 25.2 because the old LND binary cannot parse Bitcoin Core 31.1's changed RPC warnings format. Fresh 0.21.4 operation is separately tested against Bitcoin Core 31.1. + +For a real installation, stop LND cleanly, take a complete offline backup of the `.lnd` directory and channel backups, and rehearse this path on a protected copy. Run only one instance of the wallet identity; never connect an old copy in parallel or roll an actively used migrated channel database back to its pre-upgrade snapshot. Follow the upstream operator migration guidance. Start the intermediate release on the real database only as part of that controlled upgrade, unlock and verify it, stop it cleanly, then install the new package and set `LND_MIGRATION_CONFIRMED=true`. Check node identity, channels, balances and backup state. Keep the intermediate image off the normal public service path. + +This patch's regression uses no channels, payments or funded wallets. It does not certify migration of every historical channel/payment/watchtower database or external plugin, or replace an operator's backup/recovery review. Only amd64 is currently validated with the official upstream images. The old package's seedless-wallet flag and dangling macaroon link are removed; LND handles certificate and macaroon generation. + +`BITCOIND_RPCPORT` can select the appropriate RPC port. `BITCOIND_RPC_POLLING=true` selects RPC polling; otherwise the existing ZMQ ports remain in use. The package no longer queries an external IP service during startup; set `EXT_IP` when advertisement is needed. + +## Reproduce the regression + +The GitHub Actions workflow builds the checksum-pinned Bitcoin and legacy LND fixtures and runs both the staged old-wallet test and fresh-wallet test. All containers use an internal Docker network, disposable volumes and zero wallet balance. Raw wallet logs contain generated test material and must not be distributed as part of the PR handoff. + +Sources: + +- [RTL security release](https://github.com/Ride-The-Lightning/RTL/releases/tag/v0.15.15) +- [LND target release](https://github.com/lightningnetwork/lnd/releases/tag/v0.21.4-beta) +- [LND operator upgrade guidance](https://docs.lightning.engineering/lightning-network-tools/lnd/run-lnd) +- [LND migration guidance](https://docs.lightning.engineering/lightning-network-tools/lnd/migrating-lnd) + + +## Split-service update (package 0.5.0) + +This revision adopts PR #24's two-service layout using current, digest-pinned official images. LND stays at 0.21.4-beta; RTL stays at 0.15.15 with Node 22.23.4 and the verified-TLS source patch. The change from one service to `lnd` and `rtl` requires an operator configuration review even if the wallet is already on LND 0.21.4. + +1. Stop the old package cleanly and take a complete offline backup of its `.lnd` data and RTL channel backups. Complete/rehearse the staged LND migration above when starting from 0.14.1. Keep only one running instance of the wallet identity. +2. Preserve the named `lndconfig_data` and `lndconfig_backup` volumes. The new `lnd` service must mount the existing `.lnd` contents at `/root/.lnd`; the new `rtl` service must mount the existing backup contents at `/RTL/backup`. The logical volume names are deliberately retained. Review any custom bind mounts or disk locations before installing; service names and mount destinations have changed. +3. Reapply Bitcoin RPC credentials, network, alias and other LND settings to **`lnd`**. Supply the desired UI password as **`rtl.APP_PASSWORD`** through the installation form or Config. Environment values that belonged to the old single service must not be assumed to move automatically to the new services. +4. Start with the new `lnd_shared` volume mounted read/write in LND and read-only in RTL. LND generates its public certificate at `/shared/tls.cert` (temporarily `/shared/tls.cert.tmp` in encrypted-key mode) and admin macaroon at `/shared/admin.macaroon`. Its private TLS key stays at `/root/.lnd/tls.key`. Changing the certificate path can cause LND to generate a new TLS certificate/key pair; it does not change the Lightning wallet identity. External clients that pinned the old public certificate must update their trust through a controlled local export. +5. Set `lnd.LND_MIGRATION_CONFIRMED=true` only after the required legacy migration, when an existing wallet has no readiness marker. Unlock through RTL. Verify wallet identity, channels, balances and backup state before returning the node to normal use. The UI is now `http://rtl.lightning-network.dappnode`; LND REST is `https://lnd.lightning-network.dappnode:8080`. + +RTL has no mount of the wallet database, TLS private key or raw LND configuration. Its read-only shared admin macaroon still authorizes wallet administration. Export a public certificate from the LND service's `/shared/tls.cert` when configuring a separate trusted client; never share `tls.key`. Local `lncli` use now needs `--tlscertpath=/shared/tls.cert --macaroonpath=/shared/admin.macaroon` in addition to the appropriate `--network`. + +The isolated regression verifies fresh wallet creation through RTL (including encrypted-key TLS), migration of an unfunded 0.14.1 wallet through 0.15.5 into the new LND service, preserved wallet identity and zero balance, and independent RTL/LND restarts. It reuses the test wallet volume directly. Installation on an actual Dappnode with customized mounts, funded channels or historical plugin databases remains an operator acceptance step; no live installation is performed by this change. diff --git a/README.md b/README.md index 4631391..e2d7052 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,65 @@ -# Lightning Network DAppNode Package +# Lightning Network Dappnode package -[![DAppNodeStore Available](https://img.shields.io/badge/DAppNodeStore-Available-brightgreen.svg)](http://my.admin.dnp.dappnode.eth/#/installer/ln.dnp.dappnode.eth) +Run LND and the Ride The Lightning web interface as two services. This update adopts the separate-service and official-image approach proposed in [PR #24](https://github.com/dappnode/DAppNodePackage-LightningNetwork/pull/24), with current security versions and verified RTL-to-LND TLS. -This package makes it easy to deploy a [Lightning Network Daemon](https://github.com/lightningnetwork/lnd) in a DAppNode. It also includes a web interface [Ride The Lightning](https://github.com/ShahanaFarooqui/RTL/) to manage the node. +| Service | Upstream | Package changes | +| --- | --- | --- | +| `lnd` | Official `lightninglabs/lnd:v0.21.4-beta`, pinned by digest | Startup configuration, migration guard and refreshed system CA trust. | +| `rtl` | Official `shahanafarooqui/rtl:v0.15.15`, pinned by digest; Node 22.23.4 | Retain the official frontend/dependencies, apply our verified-TLS backend patch and refresh system CA trust. | -[Summary](https://lightning.network/lightning-network-summary.pdf) +The provisional package version is **0.5.0** because the service layout and operator configuration change. Only amd64 is currently validated. -[The Bitcoin Lightning Network: Paper (PDF)](https://lightning.network/lightning-network-paper.pdf) +## Installation and UI -# Warning :warning: +Install a compatible, synchronized Bitcoin package. Choose an RTL password in the Dappnode installation form, then open `http://rtl.lightning-network.dappnode` and sign in with that password. The password is provided only to RTL. LND generates the wallet's credentials and TLS certificates itself; wallet creation/unlock works through RTL. -Both LND and RTL are beta software. Don't be completely #reckless. +**Existing installations must follow [MIGRATION.md](MIGRATION.md)** before updating. A direct LND 0.14.1 → 0.21.4 database upgrade failed in our isolated test. The tested intermediate release is LND 0.15.5. -It is recommended to save backups every time you update the package, or even from time to time with a cron job. -For many reasons we don't save any seed to restore the wallet, and having an up to date backup is crucial to not lose any funds in case something bad happens. +## Service and data boundaries -# Backup LND data +- `lndconfig_data` stays mounted at `/root/.lnd` in LND. The wallet database and TLS private key are never mounted into RTL. +- `lnd_shared` contains the public TLS certificate(s) and admin macaroon. LND writes it; RTL mounts it read-only at `/shared`. +- `lndconfig_backup` remains the dedicated RTL backup volume, now mounted at `/RTL/backup`. +- Only LND's peer port `9735` is published on the host. RTL and LND REST use Dappnode's internal service network; gRPC retains LND's default local listener. -LND data is stored permanently in a docker volume, but it is recommended to save it apart in case of an update or migration. +RTL remains an administrator of LND through its admin macaroon; separating private-key storage does not restrict the wallet operations that this credential authorizes. Package backups contain sensitive wallet/credential material and must be protected. -When you enter the LN package (under Packages/ My Packages) you will see a Backup tab +## Verified TLS -

+RTL uses `https://lnd.lightning-network.dappnode:8080`. LND includes that hostname in its certificate. The RTL patch trusts `/shared/tls.cert` and, while encrypted-key LND is locked, `/shared/tls.cert.tmp`. It retains hostname and expiry verification, binds requests to the configured HTTPS origin, and follows no credentialed redirects. -Hit the "Backup now" button and select where do you want to keep your backup file safe. A tar.xz file will be downloaded to the selected path. +Each new connection reads the current local public certificate files. Missing, malformed, untrusted, expired or wrong-hostname certificates fail safely while the UI remains available. Certificate renewal and temporary-to-permanent unlock transitions need no RTL restart. No private-key loading, network trust discovery, insecure retry or automatic payment replay is used. -# Restore LND data +The TLS settings and credentialed origin are server-owned. The browser settings API cannot replace them. LND's raw configuration file, which can contain Bitcoin RPC credentials, is not mounted into RTL. The separate upstream CLN WebSocket transport is outside this LND package's scope. -If anything happens with your LN node and you have your backup you can always restore it from the ADMIN UI. Just go to Packages / My Packages and select the LN package and the backup tab. Aside the backup button you will see a "Restore" button. +## Configuration -Just hit that sweet restore button, select your tar.xz backup file and confirm. +| Service | Variables | +| --- | --- | +| `rtl` | `APP_PASSWORD` — required, non-default UI password. | +| `lnd` | `NETWORK` (`mainnet` by default), `RPCUSER`, `RPCPASS`, `BITCOIND_HOST`, optional `BITCOIND_RPCPORT`, `BITCOIND_RPC_POLLING`, `ALIAS`, `COLOR`, `EXT_IP`. | +| `lnd` | `LND_MIGRATION_CONFIRMED` — only set after the documented staged migration of an existing unmarked wallet. | -

+TLS certificate/macaroons paths and service hostname are set by LND's wrapper arguments. RTL's matching trust/origin configuration is installed by its wrapper. Existing LND configuration files are retained; the wrapper does not rewrite the operator's Bitcoin credentials into a shared file. -After the backup file is uploaded your LN node is restored. - -# Using the LN package - -**Before downloading the Ligtning Package, we assume previous understanding of the Lightning Network. Otherwise, please have a look at the following documents: - -[lightning-network-summary](https://lightning.network/lightning-network-summary.pdf), [The Bitcoin Lightning Network: Paper (PDF)](https://lightning.network/lightning-network-paper.pdf) and other valuable resources about LN [here](https://lnroute.com/)** - -* This package contains both [Lightning Network Daemon](https://github.com/lightningnetwork/lnd) and [Ride The Lightning](https://github.com/ShahanaFarooqui/RTL) web interface - -* You need to have the Bitcoin node package (without pruning) installed and fully synced for the package to work properly. You don't need to wait for the Bitcoin Core node to be fully synced to install the LN package, but it won't be functional until the node is synced. - -* The private keys of your LN wallet are stored in your LN node data volume. **Please follow the guide about backups in this document to prevent loss of funds. We don't offer a seed backup mechanism so this is the only way to recover your funds in case something goes wrong**. - -* When installing LN in DAppNode there will be two sets of username/password: - * One to connect to the Bitcoin node, RPCUSER and RPCPASS (make sure they are the same as the Bitcoin package) and - * Another one, RTL_PASSWORD, to allow access to the web UI and execute transactions/use the node. - * Remember to change these if you are sharing your DAppNode with others! Take in account that **if you give admin access to your DAppNode to anyone, that access implies full control of your LN node** including accessing your funds. - -* You can connect your favourite LN mobile wallet by opening a private channel with your node. - -* When updating this package, volumes will not be affected. You can access an updated version of your LN node witbout risk of losing any funds. Do not remove the volume though, or you will lose the funds on your node if you have not followed the backup instructions. - -## Accessing the ADMIN UI - -Once the node is synced, you can access your LN node [admin UI here](https://lightning-network.dappnode) - -## How to download macaroons - -Usually Lightning Network applications require files called *macaroons* for authorizations to perform operations on the LND node. There are many types depending on the level of access. - -To download the admin macaroon, you should go to the Admin panel of DAppnode: - -Packages -> My packages -> Lightning-Network -> File manager - -Then input in the "Download from DNP" field: +## Build and validation +```sh +docker compose build ``` -/config/data/chain/bitcoin/mainnet/admin.macaroon -``` - -## How to use Joule extension with DAppNode - -Joule is an extension available for many browsers which lets you use your node to make payments, invoices, open channels and more. Check the website: https://lightningjoule.com/ -* To run Joule, first you need to download these macaroons in a safe folder, as described above: +For local validation, the harness defaults use these image aliases: +```sh +docker build -t codex/lightning-lnd:split-20261023 buildLnd +docker build -t codex/lightning-rtl:split-20261023 buildRtl +python3 tests/runtime-smoke.py --output /tmp/lightning-runtime-private +python3 tests/regtest-smoke.py --fresh --output /tmp/lightning-fresh-private +python3 tests/regtest-smoke.py --fresh --encrypted-tls --output /tmp/lightning-encrypted-private ``` -/config/data/chain/bitcoin/mainnet/admin.macaroon -/config/data/chain/bitcoin/mainnet/readonly.macaroon -``` - -* When asked on the type of node, select Remote and then enter the following url: - - https://lightning-network.dappnode:8080 - * You will need to accept the SSL certificate in the next step - -* Upload the macaroons, choose a password to encrypt the data, and you're ready to go! - - -* **Enjoy!** But be aware both LND and RTL are beta software .Only use funds you can afford to lose. Don't be completely #Reckless ;) - - - -# Prerequisites - -- git - - Install [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) commandline tool. - -- docker - - Install [docker](https://docs.docker.com/engine/installation). The community edition (docker-ce) will work. In Linux make sure you grant permissions to the current user to use docker by adding current user to docker group, `sudo usermod -aG docker $USER`. Once you update the users group, exit from the current terminal and open a new one to make effect. - -- docker-compose - - Install [docker-compose](https://docs.docker.com/compose/install) - -**Note**: Make sure you can run `git`, `docker ps`, `docker-compose` without any issue and without sudo command. - - -## Building - -`docker-compose build` - -## Running - -### Start - -`docker-compose up -d` - -### View logs - -`docker-compose logs -f` - -### Stop - -`docker-compose down` - -## Environment variables - -You can edit the `docker-compose.yml` and add extra options, such a: - -| name | default | -| ---- | ------- | -| RPCUSER | dappnode | -| RPCPASS | dappnode | -| BITCOIND_HOST | my.bitcoin.dnp.dappnode.eth | -| NETWORK | mainnet | -| ALIAS | | -| COLOR | | -| EXT_IP | | -## License +Build the pinned Bitcoin/legacy LND test fixtures and run the staged regression using the commands in `.github/workflows/node-runtime-test.yml`. All tests use synthetic credentials, unfunded wallets, disposable volumes and an internal Docker network. Do not distribute raw wallet logs; share the JSON summaries only. -This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details +The RTL image build checks the pinned source commit, applies `buildRtl/patches/rtl-lnd-tls-verification.patch`, compiles the patched backend and runs all 397 backend tests as an unprivileged user. The official frontend is retained. The runtime harness covers UI/password/CSRF, WebSocket upgrade, wrong/untrusted certificates, read-only sharing, renewal, backup preservation and independent service restarts. +No funded channels, payment/watchtower database migration, ARM64 build or Angular browser suite is certified by these tests. This source update is not an automatic production-wallet migration. diff --git a/buildLnd/Dockerfile b/buildLnd/Dockerfile new file mode 100644 index 0000000..24c86e9 --- /dev/null +++ b/buildLnd/Dockerfile @@ -0,0 +1,8 @@ +ARG LND_IMAGE=lightninglabs/lnd:v0.21.4-beta@sha256:04d06edcc0f6e99a5e797ea34959f330add222226f840cd86afbc31f8f29627d +FROM ${LND_IMAGE} +# Keep the official LND binaries and refresh system trust. +RUN apk add --no-cache --upgrade 'ca-certificates>=20261020' 'ca-certificates-bundle>=20261020' +COPY entrypoint.sh /opt/dappnode/entrypoint.sh +RUN chmod 755 /opt/dappnode/entrypoint.sh +EXPOSE 8080 9735 10009 +ENTRYPOINT ["/opt/dappnode/entrypoint.sh"] diff --git a/buildLnd/entrypoint.sh b/buildLnd/entrypoint.sh new file mode 100644 index 0000000..e88258b --- /dev/null +++ b/buildLnd/entrypoint.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +NETWORK=${NETWORK:-mainnet} +BITCOIND_HOST=${BITCOIND_HOST:-bitcoin.dappnode} +RPCUSER=${RPCUSER:-dappnode} +RPCPASS=${RPCPASS:-dappnode} +case "$NETWORK" in + mainnet) default_rpcport=8332 ;; + testnet) default_rpcport=18332 ;; + signet) default_rpcport=38332 ;; + regtest) default_rpcport=18443 ;; + *) echo "Unsupported Bitcoin network: $NETWORK" >&2; exit 1 ;; +esac +BITCOIND_RPCPORT=${BITCOIND_RPCPORT:-$default_rpcport} + +state=${HOME:-/root}/.lnd +marker=$state/.dappnode-lnd-0.21-ready +wallet=$state/data/chain/bitcoin/$NETWORK/wallet.db +if [[ -f $wallet && ! -f $marker && ${LND_MIGRATION_CONFIRMED:-false} != true ]]; then + echo "Existing LND wallet requires the staged upgrade in MIGRATION.md. Set LND_MIGRATION_CONFIRMED=true only after completing it on a backed-up installation." >&2 + exit 1 +fi +mkdir -p "$state" /shared +touch "$marker" + +# LND generates its own TLS certificate and network-specific macaroon files. +# Never create a dangling admin.macaroon symlink or an unbacked seedless wallet. +args=( + --bitcoin.active "--bitcoin.$NETWORK" --bitcoin.node=bitcoind + "--bitcoind.rpchost=$BITCOIND_HOST:$BITCOIND_RPCPORT" + "--bitcoind.rpcuser=$RPCUSER" "--bitcoind.rpcpass=$RPCPASS" + --tlscertpath=/shared/tls.cert --tlskeypath=/root/.lnd/tls.key + --adminmacaroonpath=/shared/admin.macaroon + --tlsextradomain=lnd.lightning-network.dappnode --tlsextradomain=lnd + --tlsautorefresh + --restlisten=0.0.0.0:8080 "--debuglevel=${DEBUG:-info}" + "--alias=${ALIAS:-Dappnode}" "--color=${COLOR:-#5ACDC5}" +) +if [[ ${BITCOIND_RPC_POLLING:-false} == true ]]; then + args+=(--bitcoind.rpcpolling) +else + args+=("--bitcoind.zmqpubrawblock=tcp://$BITCOIND_HOST:28332" + "--bitcoind.zmqpubrawtx=tcp://$BITCOIND_HOST:28333") +fi +if [[ -n ${EXT_IP:-} ]]; then args+=("--externalip=$EXT_IP"); fi +exec lnd "${args[@]}" "$@" diff --git a/buildRtl/Dockerfile b/buildRtl/Dockerfile new file mode 100644 index 0000000..0077ecc --- /dev/null +++ b/buildRtl/Dockerfile @@ -0,0 +1,30 @@ +ARG RTL_IMAGE=shahanafarooqui/rtl:v0.15.15@sha256:fdf3c8dc5843eccd556e475265487b2be24478727cd160e51127ea65c2a3bbb6 +FROM ${RTL_IMAGE} AS upstream + +FROM upstream AS patched +ARG RTL_TAG=v0.15.15 +ARG RTL_COMMIT=9afea82871c4f176b38ea31190d696344985a917 +RUN apk add --no-cache git openssl +WORKDIR /build/RTL +RUN git clone --depth 1 -b "$RTL_TAG" https://github.com/Ride-The-Lightning/RTL.git . \ + && test "$(git rev-parse HEAD)" = "$RTL_COMMIT" +# Retain the official frontend; compile only the backend requiring the TLS source patch. +RUN cmp package.json /RTL/package.json && npm ci --legacy-peer-deps +COPY patches/rtl-lnd-tls-verification.patch /tmp/rtl-lnd-tls-verification.patch +RUN git apply --check /tmp/rtl-lnd-tls-verification.patch \ + && git apply /tmp/rtl-lnd-tls-verification.patch \ + && npm run buildbackend +RUN cp -a /RTL/frontend ./frontend && chown -R node:node /build/RTL +USER node +RUN npm run testbackend + +FROM upstream +RUN test "$(node --version)" = v22.23.4 \ + && apk add --no-cache --upgrade 'ca-certificates>=20261020' 'ca-certificates-bundle>=20261020' +COPY --from=patched /build/RTL/backend /RTL/backend +COPY RTL-Config.json render-config.cjs entrypoint.sh /opt/dappnode/ +RUN chmod 755 /opt/dappnode/entrypoint.sh +ENV CHANNEL_BACKUP_PATH=/RTL/backup/node-1 +WORKDIR /RTL +EXPOSE 80 +ENTRYPOINT ["/sbin/tini", "-g", "--", "/opt/dappnode/entrypoint.sh"] diff --git a/src/config/source_RTL-Config.json b/buildRtl/RTL-Config.json similarity index 58% rename from src/config/source_RTL-Config.json rename to buildRtl/RTL-Config.json index 9eaa339..4677977 100644 --- a/src/config/source_RTL-Config.json +++ b/buildRtl/RTL-Config.json @@ -11,17 +11,19 @@ "index": 1, "lnNode": "Node 1", "lnImplementation": "LND", - "Authentication": { - "macaroonPath": "/root/.lnd/data/chain/bitcoin/mainnet", - "configPath": "/root/.lnd/lnd.conf" + "authentication": { + "tlsCertPath": "/shared/tls.cert", + "tlsAllowEphemeral": true, + "macaroonPath": "/shared", + "configPath": "" }, - "Settings": { + "settings": { "userPersona": "MERCHANT", "themeMode": "NIGHT", "themeColor": "TEAL", - "channelBackupPath": "/root/backup/node-1", + "channelBackupPath": "/RTL/backup/node-1", "logLevel": "ERROR", - "lnServerUrl": "https://localhost:8080", + "lnServerUrl": "https://lnd.lightning-network.dappnode:8080", "fiatConversion": false } } diff --git a/buildRtl/entrypoint.sh b/buildRtl/entrypoint.sh new file mode 100644 index 0000000..252724d --- /dev/null +++ b/buildRtl/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +umask 077 +node /opt/dappnode/render-config.cjs +cd /RTL +exec node rtl.js diff --git a/buildRtl/patches/rtl-lnd-tls-verification.patch b/buildRtl/patches/rtl-lnd-tls-verification.patch new file mode 100644 index 0000000..c70c8df --- /dev/null +++ b/buildRtl/patches/rtl-lnd-tls-verification.patch @@ -0,0 +1,1244 @@ +diff --git a/server/controllers/lnd/webSocketClient.ts b/server/controllers/lnd/webSocketClient.ts +index ac20353..7c1e352 100644 +--- a/server/controllers/lnd/webSocketClient.ts ++++ b/server/controllers/lnd/webSocketClient.ts +@@ -1,4 +1,5 @@ + import request from '../../utils/request.js'; ++import { lndTlsOptions } from '../../utils/lndTls.js'; + import * as fs from 'fs'; + import { join } from 'path'; + +@@ -115,7 +116,7 @@ export class LNDWebSocketClient { + this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Invoice ' + 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: url + '/v2/invoices/subscribe/' + rHash, timeout: 0, signal: subscription.controller.signal }; ++ options = { ...options, ...lndTlsOptions(selectedNode), url: url + '/v2/invoices/subscribe/' + rHash, timeout: 0, signal: subscription.controller.signal }; + request(options).then((msg) => { + ended(); + this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Invoice Information Received for ' + rHash }); +@@ -145,7 +146,7 @@ export class LNDWebSocketClient { + this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Payment ' + 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 }; ++ options = { ...options, ...lndTlsOptions(selectedNode), 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'; +@@ -161,7 +162,7 @@ export class LNDWebSocketClient { + }; + + public setOptionsForSelNode = (selectedNode: SelectedNode) => { +- const options = { url: '', rejectUnauthorized: false, json: true, form: null }; ++ const options = { ...lndTlsOptions(selectedNode), url: '', rejectUnauthorized: true, json: true, form: null }; + try { + options['headers'] = { 'Grpc-Metadata-macaroon': fs.readFileSync(join(selectedNode.authentication.macaroonPath, 'admin.macaroon')).toString('hex') }; + } catch (err) { +diff --git a/server/models/config.model.ts b/server/models/config.model.ts +index e35e7aa..927d150 100644 +--- a/server/models/config.model.ts ++++ b/server/models/config.model.ts +@@ -41,7 +41,9 @@ export class Authentication { + public runePath?: string, + public runeValue?: string, + public lnApiPassword?: string, +- public swapMacaroonPath?: string ++ public swapMacaroonPath?: string, ++ public tlsCertPath?: string, ++ public tlsAllowEphemeral?: boolean + ) { } + + } +diff --git a/server/utils/common.ts b/server/utils/common.ts +index edba8b1..e58f249 100644 +--- a/server/utils/common.ts ++++ b/server/utils/common.ts +@@ -4,6 +4,7 @@ import { join, dirname, isAbsolute, resolve, sep } from 'path'; + import { fileURLToPath } from 'url'; + import * as crypto from 'crypto'; + import request from './request.js'; ++import { lndTlsOptions } from './lndTls.js'; + import { Logger, LoggerService } from './logger.js'; + import { ApplicationConfig, SelectedNode, Settings } from '../models/config.model.js'; + +@@ -71,6 +72,8 @@ export class CommonService { + delete node.authentication.runeValue; + delete node.authentication.lnApiPassword; + delete node.authentication.options; ++ delete node.authentication.tlsCertPath; ++ delete node.authentication.tlsAllowEphemeral; + } + return node; + }; +@@ -173,6 +176,8 @@ export class CommonService { + node.authentication.runePath = runtimeNode.authentication?.runePath; + node.authentication.lnApiPassword = runtimeNode.authentication?.lnApiPassword; + node.authentication.configPath = runtimeNode.authentication?.configPath; ++ node.authentication.tlsCertPath = runtimeNode.authentication?.tlsCertPath; ++ node.authentication.tlsAllowEphemeral = runtimeNode.authentication?.tlsAllowEphemeral; + // runeValue is CLN's live rune (setOptions sends it verbatim as the request + // header with no file read), so it is pinned like the other credentials even + // though the persisted file is stripped of it at save time. +@@ -204,6 +209,8 @@ export class CommonService { + delete node.authentication.runePath; + delete node.authentication.lnApiPassword; + delete node.authentication.configPath; ++ delete node.authentication.tlsCertPath; ++ delete node.authentication.tlsAllowEphemeral; + // The live rune value must be stripped too, not just the path pointer. + delete node.authentication.runeValue; + delete node.authentication.options; +@@ -226,7 +233,7 @@ export class CommonService { + const swapOptions = { + baseUrl: req.session.selectedNode.settings.swapServerUrl, + uri: '', +- rejectUnauthorized: false, ++ rejectUnauthorized: true, + json: true, + headers: { 'Grpc-Metadata-macaroon': '' } + }; +@@ -243,6 +250,7 @@ export class CommonService { + + public getOptions = (req) => { + if (req.session.selectedNode && req.session.selectedNode.authentication.options) { ++ Object.assign(req.session.selectedNode.authentication.options, { rejectUnauthorized: true }, lndTlsOptions(req.session.selectedNode)); + req.session.selectedNode.authentication.options.method = (req.session.selectedNode.lnImplementation && req.session.selectedNode.lnImplementation.toUpperCase() === 'LND') ? 'GET' : 'POST'; + delete req.session.selectedNode.authentication.options.form; + delete req.session.selectedNode.authentication.options.body; +@@ -260,8 +268,9 @@ export class CommonService { + return { status: 401, message: 'Session expired after a day\'s inactivity' }; + } + req.session.selectedNode.authentication.options = { ++ ...lndTlsOptions(req.session.selectedNode), + url: '', +- rejectUnauthorized: false, ++ rejectUnauthorized: true, + json: true, + form: null + }; +@@ -294,8 +303,9 @@ export class CommonService { + return { status: 200, message: 'Updated Successfully' }; + } catch (err) { + req.session.selectedNode.authentication.options = { ++ ...lndTlsOptions(req.session.selectedNode), + url: '', +- rejectUnauthorized: false, ++ rejectUnauthorized: true, + json: true, + form: null + }; +@@ -320,8 +330,9 @@ export class CommonService { + this.nodes.forEach((node) => { + if (node.authentication.options && node.authentication.options.headers) { return; } + node.authentication.options = { ++ ...lndTlsOptions(node), + url: '', +- rejectUnauthorized: false, ++ rejectUnauthorized: true, + json: true, + form: null + }; +@@ -351,8 +362,9 @@ export class CommonService { + } catch (err) { + this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Common Set Options Error', error: err }); + node.authentication.options = { ++ ...lndTlsOptions(node), + url: '', +- rejectUnauthorized: false, ++ rejectUnauthorized: true, + json: true, + form: '' + }; +@@ -637,7 +649,8 @@ export class CommonService { + } + const options = { + url: node.settings.lnServerUrl + '/v1/channels/backup', +- rejectUnauthorized: false, ++ ...lndTlsOptions(node), ++ rejectUnauthorized: true, + json: true, + headers: { 'Grpc-Metadata-macaroon': macaroon } + }; +diff --git a/server/utils/config.ts b/server/utils/config.ts +index 916912b..a4c98b8 100644 +--- a/server/utils/config.ts ++++ b/server/utils/config.ts +@@ -1,6 +1,6 @@ + import * as os from 'os'; + import * as fs from 'fs'; +-import { join, dirname, sep } from 'path'; ++import { join, dirname, sep, isAbsolute } from 'path'; + import { fileURLToPath } from 'url'; + import * as crypto from 'crypto'; + import ini from 'ini'; +@@ -208,6 +208,17 @@ export class ConfigService { + } + break; + } ++ const tlsCertPath = node.authentication?.tlsCertPath; ++ const tlsAllowEphemeral = node.authentication?.tlsAllowEphemeral; ++ if (tlsCertPath !== undefined && (typeof tlsCertPath !== 'string' || !isAbsolute(tlsCertPath))) { ++ this.errMsg += '\nLND tlsCertPath must be an absolute public certificate path.'; ++ } ++ if (tlsAllowEphemeral !== undefined && typeof tlsAllowEphemeral !== 'boolean') { ++ this.errMsg += '\nLND tlsAllowEphemeral must be a boolean.'; ++ } ++ if (tlsAllowEphemeral === true && !tlsCertPath) { this.errMsg += '\nLND tlsAllowEphemeral requires tlsCertPath.'; } ++ this.common.nodes[idx].authentication.tlsCertPath = tlsCertPath; ++ this.common.nodes[idx].authentication.tlsAllowEphemeral = tlsAllowEphemeral; + if (process?.env?.CONFIG_PATH) { + this.common.nodes[idx].authentication.configPath = process?.env?.CONFIG_PATH; + } else if (node.authentication && node.authentication.configPath) { +diff --git a/server/utils/lndTls.ts b/server/utils/lndTls.ts +new file mode 100644 +index 0000000..ed36818 +--- /dev/null ++++ b/server/utils/lndTls.ts +@@ -0,0 +1,84 @@ ++import { closeSync, fstatSync, openSync, readFileSync } from 'node:fs'; ++import { Agent } from 'node:https'; ++import { isAbsolute } from 'node:path'; ++import { X509Certificate } from 'node:crypto'; ++ ++export interface LndTlsPolicy { ++ origin: string; ++ certPath?: string; ++ allowEphemeral?: boolean; ++} ++ ++const tlsError = (code: string, message: string): Error & { code: string } => Object.assign(new Error(message), { code }); ++ ++// Keep paths and the credentialed origin in server-owned node configuration. Carry plain ++// metadata, not Agent instances, because RTL clones selected nodes into sessions. ++export const lndTlsOptions = (node) => { ++ if ((node?.lnImplementation || 'LND').toUpperCase() !== 'LND') { return {}; } ++ return { ++ lndTls: { ++ origin: node?.settings?.lnServerUrl, ++ certPath: node?.authentication?.tlsCertPath, ++ allowEphemeral: node?.authentication?.tlsAllowEphemeral === true ++ } as LndTlsPolicy ++ }; ++}; ++ ++const readCertificate = (path: string): Buffer | undefined => { ++ let fd: number; ++ try { ++ fd = openSync(path, 'r'); ++ } catch (err) { ++ if (err.code === 'ENOENT') { return undefined; } ++ throw tlsError('LND_TLS_CERT_UNAVAILABLE', 'Cannot read the configured LND TLS certificate.'); ++ } ++ try { ++ const stat = fstatSync(fd); ++ if (!stat.isFile() || stat.size === 0 || stat.size > 65536) { throw new Error('Invalid certificate file'); } ++ const data = readFileSync(fd); ++ const text = data.toString('utf8'); ++ const certs = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); ++ if (!certs?.length || /PRIVATE KEY/.test(text)) { throw new Error('Public certificates only'); } ++ for (const pem of certs) { new X509Certificate(pem); } ++ return data; ++ } catch { ++ throw tlsError('LND_TLS_CERT_INVALID', 'The configured LND TLS certificate is invalid or not ready.'); ++ } finally { ++ closeSync(fd); ++ } ++}; ++ ++export const createLndTlsAgent = (policy: LndTlsPolicy, url: string, baseUrl?: string): Agent => { ++ let expected: URL; ++ let target: URL; ++ try { ++ expected = new URL(policy.origin); ++ target = baseUrl ? new URL(url, baseUrl) : new URL(url); ++ } catch { ++ throw tlsError('LND_TLS_ORIGIN', 'Invalid LND HTTPS endpoint configuration.'); ++ } ++ if (expected.protocol !== 'https:' || target.protocol !== 'https:' || target.origin !== expected.origin || ++ expected.username || expected.password || expected.search || expected.hash || target.username || target.password) { ++ throw tlsError('LND_TLS_ORIGIN', 'LND requests require HTTPS to the configured node origin.'); ++ } ++ ++ let ca: Buffer[] | undefined; ++ if (policy.certPath !== undefined) { ++ if (typeof policy.certPath !== 'string' || !isAbsolute(policy.certPath)) { ++ throw tlsError('LND_TLS_CERT_INVALID', 'LND tlsCertPath must be an absolute public certificate path.'); ++ } ++ // A missing permanent file is legitimate while encrypted-key LND uses its local, ++ // temporary pre-unlock certificate. Never learn trust from a network handshake. ++ ca = [readCertificate(policy.certPath)]; ++ if (policy.allowEphemeral) { ca.push(readCertificate(policy.certPath + '.tmp')); } ++ ca = ca.filter((cert) => cert !== undefined); ++ if (ca.length === 0) { throw tlsError('LND_TLS_CERT_UNAVAILABLE', 'LND TLS certificate is not ready; wait for LND or check its certificate configuration.'); } ++ } else if (policy.allowEphemeral) { ++ throw tlsError('LND_TLS_CERT_INVALID', 'Temporary LND certificate trust requires tlsCertPath.'); ++ } ++ ++ // A fresh agent reads current local trust on each request. No cross-request connection ++ // or TLS-session reuse can retain an old certificate across LND renewal/unlock. ++ // Node's built-in hostname/expiry checks remain enabled. ++ return new Agent({ ca, rejectUnauthorized: true, keepAlive: false, maxCachedSessions: 0 }); ++}; +diff --git a/server/utils/request.ts b/server/utils/request.ts +index 03c3b3a..f9ee424 100644 +--- a/server/utils/request.ts ++++ b/server/utils/request.ts +@@ -1,5 +1,6 @@ + import axios from 'axios'; + import * as https from 'https'; ++import { createLndTlsAgent } from './lndTls.js'; + + // Drop-in replacement for the deprecated request-promise, backed by axios. + // Accepts the same options shape used across the controllers ({ url, baseUrl, +@@ -9,7 +10,7 @@ import * as https from 'https'; + // 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 verifiedAgent = new https.Agent({ rejectUnauthorized: true }); + + const buildConfig = (options, method: string) => { + const config: any = { +@@ -25,7 +26,6 @@ const buildConfig = (options, method: string) => { + }; + 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; } + // Lets a caller abort a call it no longer wants, such as an unbounded long poll it replaces. + if (options.signal) { config.signal = options.signal; } + if (options.form !== null && options.form !== undefined) { +@@ -55,6 +55,10 @@ const buildConfig = (options, method: string) => { + config.responseType = 'text'; + config.transformResponse = [(data) => data]; + } ++ // Legacy rejectUnauthorized:false options no longer disable verification. The local ++ // LND gets only its configured public certificate(s), bound to its exact origin. ++ config.httpsAgent = options.lndTls ? createLndTlsAgent(options.lndTls, config.url, config.baseURL) : verifiedAgent; ++ if (options.lndTls) { config.maxRedirects = 0; } + return config; + }; + +@@ -78,9 +82,18 @@ const toRequestPromiseError = (err, config) => { + }; + }; + +-const call = (options, method?: string) => { +- const config = buildConfig(options, method); +- return axios.request(config).then((response) => response.data).catch((err) => Promise.reject(toRequestPromiseError(err, config))); ++const call = async (options, method?: string) => { ++ let config: any = { method: method || options.method || 'GET' }; ++ try { ++ config = buildConfig(options, method); ++ return (await axios.request(config)).data; ++ } catch (err) { ++ // Also turn certificate-read/validation failures into rejected request promises. ++ // Controllers already handle this shape, so a not-yet-created cert cannot crash RTL. ++ throw toRequestPromiseError(err, config); ++ } finally { ++ if (options.lndTls) { config.httpsAgent?.destroy(); } ++ } + }; + + const request = (options) => call(options); +diff --git a/test/backend/boot-config.test.mjs b/test/backend/boot-config.test.mjs +index 6fbfacd..e68de43 100644 +--- a/test/backend/boot-config.test.mjs ++++ b/test/backend/boot-config.test.mjs +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; + import { spawn } from 'node:child_process'; +-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; ++import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { dirname, join } from 'node:path'; + import test, { after } from 'node:test'; +@@ -99,3 +99,32 @@ test('a well-formed trustedProxies list boots (control for the refusals above)', + const result = await boot(writeConfig('127.0.0.1, 10.0.0.0/8')); + assert.equal(result.code, 'listening', 'stderr: ' + result.stderr); + }); ++ ++for (const [auth, message] of [ ++ [{ tlsCertPath: 'relative/cert' }, /tlsCertPath must be an absolute/], ++ [{ tlsCertPath: 123 }, /tlsCertPath must be an absolute/], ++ [{ tlsAllowEphemeral: 'true' }, /tlsAllowEphemeral must be a boolean/], ++ [{ tlsAllowEphemeral: true }, /tlsAllowEphemeral requires tlsCertPath/] ++]) { ++ test('bad server-owned LND TLS configuration refuses boot: ' + JSON.stringify(auth), async () => { ++ const dir = writeConfig(); ++ const path = join(dir, 'RTL-Config.json'); ++ const config = JSON.parse(readFileSync(path, 'utf8')); ++ Object.assign(config.nodes[0].authentication, auth); ++ writeFileSync(path, JSON.stringify(config)); ++ const result = await boot(dir); ++ assert.equal(result.code, 1); ++ assert.match(result.stderr, message); ++ }); ++} ++ ++test('a configured certificate that LND has not created yet does not block the RTL UI', async () => { ++ const dir = writeConfig(); ++ const path = join(dir, 'RTL-Config.json'); ++ const config = JSON.parse(readFileSync(path, 'utf8')); ++ config.nodes[0].authentication.tlsCertPath = join(dir, 'not-created.cert'); ++ config.nodes[0].authentication.tlsAllowEphemeral = true; ++ writeFileSync(path, JSON.stringify(config)); ++ const result = await boot(dir); ++ assert.equal(result.code, 'listening', result.stderr); ++}); +diff --git a/test/backend/cross-session-options.test.mjs b/test/backend/cross-session-options.test.mjs +index 74527a0..764e490 100644 +--- a/test/backend/cross-session-options.test.mjs ++++ b/test/backend/cross-session-options.test.mjs +@@ -1,5 +1,6 @@ + import assert from 'node:assert/strict'; + import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test, { after } from 'node:test'; + + import { getAllChannels, getPendingChannels, getClosedChannels } from '../../backend/controllers/lnd/channels.js'; +@@ -35,7 +36,8 @@ const startNode = async (credHeader, responses, gatePath) => { + let gateHit; + const gateReached = new Promise((resolve) => { gateHit = resolve; }); + let gated = false; +- const server = createServer((req, res) => { ++ const secure = credHeader === 'grpc-metadata-macaroon'; ++ const server = (secure ? createLndServer : createServer)((req, res) => { + req.on('data', () => { }); + req.on('end', async () => { + const path = req.url.split('?')[0]; +@@ -53,7 +55,7 @@ const startNode = async (credHeader, responses, gatePath) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + seen, release, gateReached, +- url: `http://127.0.0.1:${server.address().port}`, ++ url: `${secure ? "https" : "http"}://127.0.0.1:${server.address().port}`, + close: () => { release(); return new Promise((resolve) => server.close(resolve)); } + }; + }; +@@ -61,7 +63,7 @@ const startNode = async (credHeader, responses, gatePath) => { + const session = (impl, index, url, headers) => ({ + selectedNode: { + index, lnNode: 'node-' + index, lnImplementation: impl, +- authentication: { options: { url: '', rejectUnauthorized: false, json: true, headers } }, ++ authentication: { tlsCertPath, options: { url: '', rejectUnauthorized: false, json: true, headers } }, + settings: { lnServerUrl: url, logLevel: 'ERROR' } + } + }); +diff --git a/test/backend/lnd-channel-backup.test.mjs b/test/backend/lnd-channel-backup.test.mjs +index 2acde45..040b78c 100644 +--- a/test/backend/lnd-channel-backup.test.mjs ++++ b/test/backend/lnd-channel-backup.test.mjs +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; + import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import { tmpdir } from 'node:os'; + import { join, parse } from 'node:path'; + import test from 'node:test'; +@@ -17,7 +17,7 @@ const BACKUP = { chan_point: { funding_txid_str: TXID, output_index: 1 }, chan_b + + const startFakeLnd = async () => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { +@@ -27,7 +27,7 @@ const startFakeLnd = async () => { + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + return { url, seen, close: () => new Promise((resolve) => server.close(resolve)) }; + }; + +@@ -35,7 +35,7 @@ const buildRequest = (url, channelBackupPath, channelPoint) => ({ + session: { + selectedNode: { + index: 1, lnNode: 'test-node', lnImplementation: 'LND', lnVersion: '0.18.0', +- authentication: { options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, ++ authentication: { tlsCertPath, options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, + settings: { lnServerUrl: url, channelBackupPath, logLevel: 'ERROR' } + } + }, +diff --git a/test/backend/lnd-channels.test.mjs b/test/backend/lnd-channels.test.mjs +index 43b838c..3e1f27a 100644 +--- a/test/backend/lnd-channels.test.mjs ++++ b/test/backend/lnd-channels.test.mjs +@@ -1,5 +1,5 @@ + import assert from 'node:assert/strict'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test from 'node:test'; + + import { postChannel } from '../../backend/controllers/lnd/channels.js'; +@@ -14,7 +14,7 @@ import { postChannel } from '../../backend/controllers/lnd/channels.js'; + + const startFakeLnd = async (macaroon) => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { +@@ -26,7 +26,7 @@ const startFakeLnd = async (macaroon) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + seen, +- url: `http://127.0.0.1:${server.address().port}`, ++ url: `https://127.0.0.1:${server.address().port}`, + macaroon, + close: () => new Promise((resolve) => server.close(resolve)) + }; +@@ -38,7 +38,7 @@ const buildRequest = (node, body) => ({ + index: 1, + lnNode: 'lnd-node', + lnImplementation: 'LND', +- authentication: { ++ authentication: { tlsCertPath, + options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': node.macaroon } } + }, + settings: { lnServerUrl: node.url, logLevel: 'ERROR' } +diff --git a/test/backend/lnd-getinfo-backup.test.mjs b/test/backend/lnd-getinfo-backup.test.mjs +index 01d8b59..9934294 100644 +--- a/test/backend/lnd-getinfo-backup.test.mjs ++++ b/test/backend/lnd-getinfo-backup.test.mjs +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; + import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import test, { after, afterEach } from 'node:test'; +@@ -28,7 +28,7 @@ const OLD_BACKUP = JSON.stringify({ multi_chan_backup: { chan_points: [], multi_ + // backup: 'ok' answers the snapshot, 'fail' answers 500, 'empty' answers 200 with no body. + const startFakeLnd = async (backup = 'ok') => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + req.on('data', () => { }); + req.on('end', () => { + seen.push(req.url); +@@ -51,7 +51,7 @@ const startFakeLnd = async (backup = 'ok') => { + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + return { url, seen, close: () => new Promise((resolve) => server.close(resolve)) }; + }; + +@@ -67,7 +67,7 @@ const makeNode = (root, index, url, { macaroon = true } = {}) => { + } + return { + index, lnNode: `node-${index}`, lnImplementation: 'LND', +- authentication: { macaroonPath }, ++ authentication: { tlsCertPath, macaroonPath }, + settings: { lnServerUrl: url, channelBackupPath, logLevel: 'ERROR' } + }; + }; +@@ -138,7 +138,7 @@ test('overlapping refreshes of one node each write their own temp file', async ( + const big = { ...BACKUP, single_chan_backups: { chan_backups: [{ chan_backup: 'x'.repeat(4 * 1024 * 1024) }] } }; + const REFRESHES = 6; + const held = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + req.on('data', () => { }); + req.on('end', () => { + held.push(res); +@@ -149,7 +149,7 @@ test('overlapping refreshes of one node each write their own temp file', async ( + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const root = mkdtempSync(join(tmpdir(), 'rtl-getinfo-')); +- const node = makeNode(root, 1, `http://127.0.0.1:${server.address().port}`); ++ const node = makeNode(root, 1, `https://127.0.0.1:${server.address().port}`); + const errors = []; + const log = Logger.log; + Logger.log = (msg) => { if (msg.level === 'ERROR') { errors.push(msg.msg + ': ' + (msg.error?.message || '')); } return log(msg); }; +diff --git a/test/backend/lnd-invoice-subscriptions.test.mjs b/test/backend/lnd-invoice-subscriptions.test.mjs +index 997490b..97ccbff 100644 +--- a/test/backend/lnd-invoice-subscriptions.test.mjs ++++ b/test/backend/lnd-invoice-subscriptions.test.mjs +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; + import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import test, { after, afterEach, beforeEach, mock } from 'node:test'; +@@ -39,7 +39,7 @@ const OPEN = [1, 2, 3].map((n) => ({ r_hash: hashOf(n), state: 'OPEN' })); + const startFakeLnd = async () => { + const subscribes = []; + const state = { invoices: OPEN }; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + req.on('data', () => { }); + req.on('end', () => { + const path = req.url.split('?')[0]; +@@ -65,7 +65,7 @@ const startFakeLnd = async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + subscribes, state, +- url: `http://127.0.0.1:${server.address().port}`, ++ url: `https://127.0.0.1:${server.address().port}`, + close: () => { server.closeAllConnections(); return new Promise((resolve) => server.close(resolve)); } + }; + }; +@@ -78,7 +78,7 @@ const makeNode = (index, url) => { + const options = { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': Buffer.from('mac-' + index).toString('hex') } }; + return { + index, lnNode: 'node-' + index, lnImplementation: 'LND', +- authentication: { macaroonPath, options }, ++ authentication: { tlsCertPath, macaroonPath, options }, + settings: { lnServerUrl: url, logLevel: 'ERROR' } + }; + }; +diff --git a/test/backend/lnd-invoices.test.mjs b/test/backend/lnd-invoices.test.mjs +index 9db7c80..6fcf077 100644 +--- a/test/backend/lnd-invoices.test.mjs ++++ b/test/backend/lnd-invoices.test.mjs +@@ -1,18 +1,18 @@ + import assert from 'node:assert/strict'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test from 'node:test'; + + import { listInvoices } from '../../backend/controllers/lnd/invoices.js'; + + const startFakeLnd = async (macaroon, invoices) => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + seen.push({ method: req.method, path: req.url, macaroon: req.headers['grpc-metadata-macaroon'] }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ invoices })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + return { + url, + seen, +@@ -26,7 +26,7 @@ const buildRequest = (node, query = {}) => ({ + index: 1, + lnNode: 'test-node', + lnImplementation: 'LND', +- authentication: { ++ authentication: { tlsCertPath, + options: { + url: '', + rejectUnauthorized: false, +diff --git a/test/backend/lnd-path-params.test.mjs b/test/backend/lnd-path-params.test.mjs +index e06abb3..81a0052 100644 +--- a/test/backend/lnd-path-params.test.mjs ++++ b/test/backend/lnd-path-params.test.mjs +@@ -1,5 +1,5 @@ + import assert from 'node:assert/strict'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test from 'node:test'; + + import { getAliasesForPubkeys, getGraphEdge, getGraphNode, getQueryRoutes, getRemoteFeePolicy } from '../../backend/controllers/lnd/graph.js'; +@@ -14,13 +14,13 @@ import { deletePeer } from '../../backend/controllers/lnd/peers.js'; + + const startFakeServer = async (reply = () => ({})) => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + seen.push({ method: req.method, path: req.url }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(reply(req.url))); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + return { url, seen, close: () => new Promise((resolve) => server.close(resolve)) }; + }; + +@@ -28,7 +28,7 @@ const buildRequest = (url, { query = {}, params = {}, body = {}, macaroon = 'mac + session: { + selectedNode: { + index: 1, lnNode: 'test-node', lnImplementation: 'LND', lnVersion: '0.18.0', +- authentication: { options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': macaroon } } }, ++ authentication: { tlsCertPath, options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': macaroon } } }, + settings: { lnServerUrl: url, logLevel: 'ERROR' } + } + }, +@@ -175,7 +175,7 @@ test('decodePayments: a payments value that is not a string is refused with 400' + const startSlowServer = async () => { + const seen = []; + const load = { inFlight: 0, max: 0 }; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + seen.push({ path: req.url, macaroon: req.headers['grpc-metadata-macaroon'] }); + load.inFlight++; + load.max = Math.max(load.max, load.inFlight); +@@ -186,7 +186,7 @@ const startSlowServer = async () => { + }, 20); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + return { url, seen, load, close: () => new Promise((resolve) => server.close(resolve)) }; + }; + +diff --git a/test/backend/lnd-payments.test.mjs b/test/backend/lnd-payments.test.mjs +index 0e35eff..1104fb1 100644 +--- a/test/backend/lnd-payments.test.mjs ++++ b/test/backend/lnd-payments.test.mjs +@@ -1,18 +1,18 @@ + import assert from 'node:assert/strict'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test from 'node:test'; + + import { getPayments } from '../../backend/controllers/lnd/payments.js'; + + const startFakeLnd = async (macaroon, payments) => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + seen.push({ method: req.method, path: req.url, macaroon: req.headers['grpc-metadata-macaroon'] }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ payments })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + return { + url, + seen, +@@ -26,7 +26,7 @@ const buildRequest = (node, query = {}) => ({ + index: 1, + lnNode: 'test-node', + lnImplementation: 'LND', +- authentication: { ++ authentication: { tlsCertPath, + options: { + url: '', + rejectUnauthorized: false, +diff --git a/test/backend/lnd-peers.test.mjs b/test/backend/lnd-peers.test.mjs +index fc6f290..43e9ab9 100644 +--- a/test/backend/lnd-peers.test.mjs ++++ b/test/backend/lnd-peers.test.mjs +@@ -1,5 +1,5 @@ + import assert from 'node:assert/strict'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test from 'node:test'; + + import { getPeers, postPeer } from '../../backend/controllers/lnd/peers.js'; +@@ -27,7 +27,7 @@ const startFakeLnd = async (name, macaroon, peers) => { + holdPeersList = { pending: null }; + }; + +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + const record = { method: req.method, path: req.url, macaroon: req.headers['grpc-metadata-macaroon'] }; + seen.push(record); + const reply = (status, body) => { +@@ -49,7 +49,7 @@ const startFakeLnd = async (name, macaroon, peers) => { + reply(404, { message: 'unexpected ' + req.method + ' ' + req.url }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + + return { + name, macaroon, url, seen, +@@ -67,7 +67,7 @@ const buildRequest = (node, body = {}) => ({ + index: 1, + lnNode: node.name, + lnImplementation: 'LND', +- authentication: { ++ authentication: { tlsCertPath, + options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': node.macaroon } } + }, + settings: { lnServerUrl: node.url, logLevel: 'ERROR' } +diff --git a/test/backend/lnd-send-payment-no-route.test.mjs b/test/backend/lnd-send-payment-no-route.test.mjs +index 61e8851..cbfdc48 100644 +--- a/test/backend/lnd-send-payment-no-route.test.mjs ++++ b/test/backend/lnd-send-payment-no-route.test.mjs +@@ -1,5 +1,5 @@ + import assert from 'node:assert/strict'; +-import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test from 'node:test'; + + import { sendPayment } from '../../backend/controllers/lnd/payments.js'; +@@ -11,7 +11,7 @@ const DEST = '0323ce56abc0fec923dd89b443f5e8a17b0bd7b64000ab703859d49d9b879e6a23 + // route query answers with a route costing `routeFeeSat`, or with an error when `routesStatus` isn't 200. + const startFakeLnd = async ({ failureReason = 'FAILURE_REASON_NO_ROUTE', decoded = {}, routeFeeSat = '500000', routesStatus = 200 } = {}) => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + seen.push(req.method + ' ' + req.url); + if (req.url === '/v2/router/send') { + res.writeHead(200, { 'Content-Type': 'application/json' }); +@@ -28,7 +28,7 @@ const startFakeLnd = async ({ failureReason = 'FAILURE_REASON_NO_ROUTE', decoded + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- return { url: `http://127.0.0.1:${server.address().port}`, seen, close: () => new Promise((resolve) => server.close(resolve)) }; ++ return { url: `https://127.0.0.1:${server.address().port}`, seen, close: () => new Promise((resolve) => server.close(resolve)) }; + }; + + const buildRequest = (lnd, body) => ({ +@@ -37,7 +37,7 @@ const buildRequest = (lnd, body) => ({ + index: 1, + lnNode: 'test-node', + lnImplementation: 'LND', +- authentication: { options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, ++ authentication: { tlsCertPath, options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, + settings: { lnServerUrl: lnd.url, logLevel: 'ERROR' } + } + }, +diff --git a/test/backend/lnd-tls.test.mjs b/test/backend/lnd-tls.test.mjs +new file mode 100644 +index 0000000..5d4ec0f +--- /dev/null ++++ b/test/backend/lnd-tls.test.mjs +@@ -0,0 +1,167 @@ ++import assert from 'node:assert/strict'; ++import { chmodSync, copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; ++import { createServer } from 'node:https'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import test, { after } from 'node:test'; ++import { certificate } from '../helpers/lnd-https.mjs'; ++import request from '../../backend/utils/request.js'; ++import { lndTlsOptions } from '../../backend/utils/lndTls.js'; ++import { Common } from '../../backend/utils/common.js'; ++import { LNDWSClient } from '../../backend/controllers/lnd/webSocketClient.js'; ++import { WSServer } from '../../backend/utils/webSocketServer.js'; ++ ++const root = mkdtempSync(join(tmpdir(), 'rtl-tls-test-')); ++after(() => { clearInterval(WSServer.pingInterval); rmSync(root, { recursive: true, force: true }); }); ++const trusted = certificate(); ++const impostor = certificate(); ++const credential = 'synthetic-macaroon-do-not-log'; ++const listen = async (pair = trusted, handler) => { ++ const seen = []; ++ const server = createServer({ key: pair.key, cert: pair.cert }, (req, res) => { ++ seen.push({ url: req.url, credential: req.headers['grpc-metadata-macaroon'] }); ++ req.resume(); ++ if (handler) { handler(req, res); } else { res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}'); } ++ }); ++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); ++ return { server, seen, url: `https://127.0.0.1:${server.address().port}`, close: () => { server.closeAllConnections(); return new Promise((resolve) => server.close(resolve)); } }; ++}; ++const options = (url, certPath = trusted.certPath, extra = {}) => ({ ++ url, json: true, timeout: 2000, rejectUnauthorized: false, ++ headers: { 'Grpc-Metadata-macaroon': credential }, ++ lndTls: { origin: url, certPath, ...extra } ++}); ++const rejects = (promise, code) => assert.rejects(promise, (error) => { ++ assert.equal(error.name, 'RequestError'); ++ if (code) { assert.match(error.error.code, code); } ++ assert.ok(!JSON.stringify(error).includes(credential)); ++ assert.ok(!JSON.stringify(error).includes('PRIVATE KEY')); ++ return true; ++}); ++ ++test('verified local certificate works even with legacy insecure options; errors omit credentials', async () => { ++ const lnd = await listen(); ++ try { ++ assert.deepEqual(await request(options(lnd.url)), { ok: true }); ++ assert.equal(lnd.seen[0].credential, credential); ++ await rejects(request(options(lnd.url, impostor.certPath))); ++ assert.equal(lnd.seen.length, 1, 'untrusted handshake sent no HTTP request'); ++ } finally { await lnd.close(); } ++}); ++ ++for (const [name, pair, code] of [ ++ ['untrusted', impostor, /SELF_SIGNED|VERIFY_LEAF/], ++ ['wrong hostname', certificate({ san: 'DNS:another-node.invalid' }), /ALTNAME/], ++ ['expired', certificate({ expired: true }), /CERT_HAS_EXPIRED/] ++]) { ++ test(`${name} certificate is rejected before any macaroon is delivered`, async () => { ++ const lnd = await listen(pair); ++ try { ++ await rejects(request(options(lnd.url, name === 'untrusted' ? trusted.certPath : pair.certPath)), code); ++ assert.deepEqual(lnd.seen, []); ++ } finally { await lnd.close(); } ++ }); ++} ++ ++test('missing, malformed, unreadable and private-key trust files fail safely and recover', async () => { ++ const lnd = await listen(); ++ const path = join(root, 'changing.cert'); ++ try { ++ await rejects(request(options(lnd.url, path)), /LND_TLS_CERT_UNAVAILABLE/); ++ for (const invalid of ['', 'not a certificate', trusted.key, Buffer.concat([trusted.cert, trusted.key])]) { ++ writeFileSync(path, invalid); ++ await rejects(request(options(lnd.url, path)), /LND_TLS_CERT_INVALID/); ++ } ++ copyFileSync(trusted.certPath, path); chmodSync(path, 0); ++ await rejects(request(options(lnd.url, path)), /LND_TLS_CERT_UNAVAILABLE/); ++ chmodSync(path, 0o600); ++ assert.deepEqual(await request(options(lnd.url, path)), { ok: true }); ++ assert.equal(lnd.seen.length, 1); ++ } finally { chmodSync(path, 0o600); await lnd.close(); } ++}); ++ ++test('rejects HTTP, mismatched origins, userinfo and relative certificate paths without connecting', async () => { ++ const lnd = await listen(); ++ try { ++ for (const target of [lnd.url.replace('https:', 'http:'), lnd.url.replace('127.0.0.1', 'localhost'), lnd.url.replace('https://', 'https://user:password@'), 'invalid']) { ++ const opts = options(lnd.url); opts.url = target; ++ await rejects(request(opts), /LND_TLS_ORIGIN/); ++ } ++ await rejects(request(options(lnd.url, './tls.cert')), /LND_TLS_CERT_INVALID/); ++ const noPath = options(lnd.url); delete noPath.lndTls.certPath; noPath.lndTls.allowEphemeral = true; ++ await rejects(request(noPath), /LND_TLS_CERT_INVALID/); ++ assert.deepEqual(lnd.seen, []); ++ } finally { await lnd.close(); } ++}); ++ ++test('credentialed redirects are rejected and a mutating request is never replayed', async () => { ++ const receiver = await listen(); ++ const sender = await listen(trusted, (_req, res) => { res.writeHead(307, { Location: receiver.url }); res.end(); }); ++ try { ++ await assert.rejects(request.post({ ...options(sender.url), body: { amount: 1 } }), (e) => e.statusCode === 307); ++ assert.equal(sender.seen.length, 1); ++ assert.deepEqual(receiver.seen, []); ++ } finally { await sender.close(); await receiver.close(); } ++}); ++ ++test('renewal reloads local trust on every request, including ephemeral-to-permanent transition', async () => { ++ const lnd = await listen(); ++ const path = join(root, 'lifecycle.cert'); ++ try { ++ copyFileSync(trusted.certPath, path + '.tmp'); ++ const opts = options(lnd.url, path, { allowEphemeral: true }); ++ assert.deepEqual(await request(opts), { ok: true }); ++ copyFileSync(impostor.certPath, path); ++ assert.deepEqual(await request(opts), { ok: true }, 'temporary certificate remains trusted during transition'); ++ lnd.server.setSecureContext({ key: impostor.key, cert: impostor.cert }); ++ assert.deepEqual(await request(opts), { ok: true }, 'new permanent certificate is trusted'); ++ rmSync(path + '.tmp'); ++ assert.deepEqual(await request(opts), { ok: true }); ++ lnd.server.setSecureContext({ key: trusted.key, cert: trusted.cert }); ++ await rejects(request(opts)); ++ copyFileSync(trusted.certPath, path); ++ assert.deepEqual(await request(opts), { ok: true }, 'renewal needs no RTL restart'); ++ assert.equal(lnd.seen.length, 5); ++ } finally { await lnd.close(); } ++}); ++ ++test('generic HTTPS uses public PKI and ignores insecure legacy options', async () => { ++ const lnd = await listen(); ++ try { ++ const opts = options(lnd.url); delete opts.lndTls; ++ await rejects(request(opts)); ++ assert.deepEqual(lnd.seen, []); ++ } finally { await lnd.close(); } ++}); ++ ++test('background backups and invoice/payment subscriptions reject an impostor and preserve backups', async () => { ++ const lnd = await listen(impostor); ++ const backup = join(root, 'channel-all.bak'); ++ writeFileSync(backup, 'existing backup'); ++ writeFileSync(join(root, 'admin.macaroon'), credential); ++ const node = { index: 999, lnNode: 'synthetic', lnImplementation: 'LND', ++ authentication: { tlsCertPath: trusted.certPath, macaroonPath: root }, ++ settings: { lnServerUrl: lnd.url, channelBackupPath: root, logLevel: 'ERROR' } }; ++ const logs = []; ++ const oldLog = Common.logger.log; ++ const oldSend = WSServer.sendErrorToAllLNClients; ++ Common.logger.log = (entry) => logs.push(entry); ++ WSServer.sendErrorToAllLNClients = (entry) => logs.push(entry); ++ try { ++ Common.getAllNodeAllChannelBackup(node); ++ const opts = LNDWSClient.setOptionsForSelNode(node); ++ assert.deepEqual(opts.lndTls, lndTlsOptions(node).lndTls); ++ LNDWSClient.subscribeToInvoice(opts, node, 'synthetic-invoice'); ++ LNDWSClient.subscribeToPayment(opts, node, 'synthetic-payment'); ++ const pending = await LNDWSClient.fetchUnpaidInvoices(node); ++ assert.ok(pending.error); ++ for (let n = 0; n < 100 && logs.filter((e) => typeof e === 'string' || e.error).length < 4; n++) { ++ await new Promise((resolve) => setTimeout(resolve, 20)); ++ } ++ assert.deepEqual(lnd.seen, []); ++ assert.equal(readFileSync(backup, 'utf8'), 'existing backup'); ++ assert.ok(logs.length >= 4); ++ assert.ok(!JSON.stringify(logs).includes(Buffer.from(credential).toString('hex'))); ++ assert.ok(!JSON.stringify(logs).includes(credential)); ++ } finally { Common.logger.log = oldLog; WSServer.sendErrorToAllLNClients = oldSend; await lnd.close(); } ++}); +diff --git a/test/backend/lnd-wallet.test.mjs b/test/backend/lnd-wallet.test.mjs +index e778b35..2ba6b62 100644 +--- a/test/backend/lnd-wallet.test.mjs ++++ b/test/backend/lnd-wallet.test.mjs +@@ -1,6 +1,7 @@ + import assert from 'node:assert/strict'; + import { readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import test, { after } from 'node:test'; +@@ -20,7 +21,7 @@ after(() => rmSync(tempDir, { recursive: true, force: true })); + + const startFakeLnd = async (reply = {}) => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = createLndServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { +@@ -30,7 +31,7 @@ const startFakeLnd = async (reply = {}) => { + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `https://127.0.0.1:${server.address().port}`; + return { url, seen, close: () => new Promise((resolve) => server.close(resolve)) }; + }; + +@@ -38,7 +39,7 @@ const buildRequest = (url, { params = {}, logLevel = 'ERROR', logFile, ...rest } + session: { + selectedNode: { + index: 1, lnNode: 'test-node', lnImplementation: 'LND', lnVersion: '0.18.0', +- authentication: { options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, ++ authentication: { tlsCertPath, options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, + settings: { lnServerUrl: url, logLevel, logFile } + } + }, +diff --git a/test/backend/node-implementation-guard.test.mjs b/test/backend/node-implementation-guard.test.mjs +index 91ef70a..90bb598 100644 +--- a/test/backend/node-implementation-guard.test.mjs ++++ b/test/backend/node-implementation-guard.test.mjs +@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'; + import { createHash } from 'node:crypto'; + import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + import { createServer as createHttpServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import { createServer } from 'node:net'; + import { tmpdir } from 'node:os'; + import { dirname, join } from 'node:path'; +@@ -34,9 +35,9 @@ const freePort = () => new Promise((resolve, reject) => { + }); + + // A stand-in node API: records each request path and answers with the given status and body. +-const startStub = (status, body) => new Promise((resolve) => { ++const startStub = (status, body, secure = false) => new Promise((resolve) => { + const hits = []; +- const server = createHttpServer((req, res) => { ++ const server = (secure ? createLndServer : createHttpServer)((req, res) => { + hits.push(req.method + ' ' + req.url); + req.resume(); + req.on('end', () => { +@@ -44,7 +45,7 @@ const startStub = (status, body) => new Promise((resolve) => { + res.end(JSON.stringify(body)); + }); + }); +- server.listen(0, '127.0.0.1', () => resolve({ server, hits, url: 'http://127.0.0.1:' + server.address().port })); ++ server.listen(0, '127.0.0.1', () => resolve({ server, hits, url: (secure ? 'https' : 'http') + '://127.0.0.1:' + server.address().port })); + }); + + const writeConfig = (port) => { +@@ -77,7 +78,7 @@ const writeConfig = (port) => { + index: 2, + lnNode: 'LND', + lnImplementation: 'LND', +- authentication: { macaroonPath: join(dir, 'macaroon'), configPath: '' }, ++ authentication: { tlsCertPath, macaroonPath: join(dir, 'macaroon'), configPath: '' }, + settings: { ...settings, lnServerUrl: stubs.lnd.url, channelBackupPath: join(dir, 'backup-2') } + }, { + index: 3, +@@ -171,7 +172,7 @@ const createInvoice = (browser, withToken) => browser.call('POST', '/rtl/api/cln + + before(async () => { + stubs.cln = await startStub(201, { payment_hash: 'ab', bolt11: 'lnbc1' }); +- stubs.lnd = await startStub(404, { code: 5, message: 'Not Found', details: [] }); ++ stubs.lnd = await startStub(404, { code: 5, message: 'Not Found', details: [] }, true); + stubs.ecl = await startStub(200, { version: '0.14.2', nodeId: '02ab', publicAddresses: [] }); + const port = await freePort(); + configDir = writeConfig(port); +diff --git a/test/backend/query-params.test.mjs b/test/backend/query-params.test.mjs +index ae9835b..945bbde 100644 +--- a/test/backend/query-params.test.mjs ++++ b/test/backend/query-params.test.mjs +@@ -1,5 +1,6 @@ + import assert from 'node:assert/strict'; + import { createServer } from 'node:http'; ++import { createServer as createLndServer, tlsCertPath } from '../helpers/lnd-https.mjs'; + import test from 'node:test'; + + import { closeChannel } from '../../backend/controllers/lnd/channels.js'; +@@ -13,9 +14,9 @@ import { loopInQuote, loopInTermsAndQuotes, loopOutQuote, loopOutTermsAndQuotes + // containing "&" smuggle extra parameters upstream. Each handler now builds its query through + // options.qs, omits what is absent and rejects what is malformed with a 400 of its own. + +-const startFakeServer = async (reply = () => ({})) => { ++const startFakeServer = async (reply = () => ({}), secure = true) => { + const seen = []; +- const server = createServer((req, res) => { ++ const server = (secure ? createLndServer : createServer)((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { +@@ -25,7 +26,7 @@ const startFakeServer = async (reply = () => ({})) => { + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +- const url = `http://127.0.0.1:${server.address().port}`; ++ const url = `${secure ? "https" : "http"}://127.0.0.1:${server.address().port}`; + return { url, seen, close: () => new Promise((resolve) => server.close(resolve)) }; + }; + +@@ -33,7 +34,7 @@ const buildRequest = (url, { query = {}, params = {}, lnVersion = '0.18.0' } = { + session: { + selectedNode: { + index: 1, lnNode: 'test-node', lnImplementation: 'LND', lnVersion, +- authentication: { options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, ++ authentication: { tlsCertPath, options: { url: '', rejectUnauthorized: false, json: true, headers: { 'Grpc-Metadata-macaroon': 'mac' } } }, + settings: { lnServerUrl: url, swapServerUrl: url, logLevel: 'ERROR' } + } + }, +@@ -159,7 +160,7 @@ test('getUTXOs (0.14+): max_confs is sent in the JSON body only when given', asy + + // Options are built per request from the session (#1714), so no prior loopInfo is needed. + test('loop quote handlers work without a prior loopInfo call', async () => { +- const loop = await startFakeServer(); ++ const loop = await startFakeServer(undefined, false); + try { + const res = await run(loopOutQuote, buildRequest(loop.url, { params: { amount: '1000' } })); + assert.equal(res.statusCode, 200, JSON.stringify(res.body)); +@@ -174,7 +175,7 @@ const LOOP_QUOTES = [ + + for (const q of LOOP_QUOTES) { + test(`${q.name}: omitted or empty deadline is left out, empty or zero targetConf keeps the default`, async () => { +- const loop = await startFakeServer(); ++ const loop = await startFakeServer(undefined, false); + try { + const res = await run(q.handler, buildRequest(loop.url, { params: { amount: '250000' } })); + assert.equal(res.statusCode, 200, JSON.stringify(res.body)); +@@ -188,7 +189,7 @@ for (const q of LOOP_QUOTES) { + }); + + test(`${q.name}: malformed amount or deadline is refused with 400`, async () => { +- const loop = await startFakeServer(); ++ const loop = await startFakeServer(undefined, false); + try { + const before = loop.seen.length; + const bad1 = await run(q.handler, buildRequest(loop.url, { params: { amount: '1&x=2' } })); +@@ -207,7 +208,7 @@ const LOOP_TERMS = [ + + for (const t of LOOP_TERMS) { + test(`${t.name}: quotes the min and max amounts separately with the given query`, async () => { +- const loop = await startFakeServer((path) => (path.endsWith('/terms') ? { min_swap_amount: '1000', max_swap_amount: '9000' } : {})); ++ const loop = await startFakeServer((path) => (path.endsWith('/terms') ? { min_swap_amount: '1000', max_swap_amount: '9000' } : {}), false); + try { + const res = await run(t.handler, buildRequest(loop.url, { query: { targetConf: '3', swapPublicationDeadline: '1700000000000' } })); + assert.equal(res.statusCode, 200, JSON.stringify(res.body)); +@@ -221,7 +222,7 @@ for (const t of LOOP_TERMS) { + }); + + test(`${t.name}: malformed targetConf is refused with 400 before the terms call`, async () => { +- const loop = await startFakeServer(); ++ const loop = await startFakeServer(undefined, false); + try { + const before = loop.seen.length; + const res = await run(t.handler, buildRequest(loop.url, { query: { targetConf: '2.5' } })); +diff --git a/test/backend/rtlconf.test.mjs b/test/backend/rtlconf.test.mjs +index e54b419..af88c55 100644 +--- a/test/backend/rtlconf.test.mjs ++++ b/test/backend/rtlconf.test.mjs +@@ -1747,3 +1747,41 @@ test('getExplorerTransaction URL-encodes the txid path segment', async () => { + await new Promise((resolve) => server.close(resolve)); + } + }); ++ ++test('settings saves preserve server-owned TLS trust and credential origin, hiding trust fields from responses', () => { ++ const dir = mkdtempSync(join(tmpdir(), 'rtlconf-tls-')); ++ const original = { ++ defaultNodeIndex: 1, dbDirectoryPath: dir, ++ SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' }, ++ nodes: [{ index: 1, lnNode: 'LND', lnImplementation: 'LND', ++ authentication: { macaroonPath: '/lnd/mac', tlsCertPath: '/lnd/tls.cert', tlsAllowEphemeral: true }, ++ settings: { lnServerUrl: 'https://localhost:8080', themeMode: 'DAY' } }] ++ }; ++ try { ++ Common.appConfig = clone({ ...original, rtlConfFilePath: dir }); ++ Common.nodes = clone(original.nodes); Common.selectedNode = Common.nodes[0]; ++ writeFileSync(join(dir, 'RTL-Config.json'), JSON.stringify(original)); ++ const input = clone(original); ++ input.nodes[0].authentication = { tlsCertPath: '/attacker/cert', tlsAllowEphemeral: false }; ++ input.nodes[0].settings = { themeMode: 'NIGHT', lnServerUrl: 'http://attacker.invalid:8080' }; ++ let response; ++ updateApplicationSettings({ body: input, session: { selectedNode: Common.selectedNode } }, { ++ status: (code) => { assert.equal(code, 201); return { json: (body) => { response = body; } }; } ++ }, null); ++ const persisted = JSON.parse(readFileSync(join(dir, 'RTL-Config.json'), 'utf8')).nodes[0]; ++ for (const node of [Common.nodes[0], Common.appConfig.nodes[0], persisted]) { ++ assert.equal(node.authentication.tlsCertPath, '/lnd/tls.cert'); ++ assert.equal(node.authentication.tlsAllowEphemeral, true); ++ assert.equal(node.settings.lnServerUrl, 'https://localhost:8080'); ++ } ++ assert.equal(response.nodes[0].authentication.tlsCertPath, undefined); ++ assert.equal(response.nodes[0].authentication.tlsAllowEphemeral, undefined); ++ updateNodeSettings({ body: { authentication: input.nodes[0].authentication, settings: input.nodes[0].settings }, ++ session: { selectedNode: Common.nodes[0] } }, { ++ status: (code) => { assert.equal(code, 201); return { json: () => {} }; } ++ }, null); ++ assert.equal(Common.nodes[0].authentication.tlsCertPath, '/lnd/tls.cert'); ++ assert.equal(Common.nodes[0].authentication.tlsAllowEphemeral, true); ++ assert.equal(Common.nodes[0].settings.lnServerUrl, 'https://localhost:8080'); ++ } finally { rmSync(dir, { recursive: true, force: true }); } ++}); +diff --git a/test/helpers/lnd-https.mjs b/test/helpers/lnd-https.mjs +new file mode 100644 +index 0000000..2291824 +--- /dev/null ++++ b/test/helpers/lnd-https.mjs +@@ -0,0 +1,43 @@ ++import { execFileSync } from 'node:child_process'; ++import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; ++import { createServer as httpsServer } from 'node:https'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++ ++const root = mkdtempSync(join(tmpdir(), 'rtl-tls-fixtures-')); ++let next = 0; ++process.on('exit', () => rmSync(root, { recursive: true, force: true })); ++ ++export function certificate({ san = 'DNS:localhost,IP:127.0.0.1,IP:::1', expired = false } = {}) { ++ const prefix = join(root, String(next++)); ++ const keyPath = prefix + '.key'; ++ const certPath = prefix + '.crt'; ++ const base = ['req', '-new', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-keyout', keyPath, '-subj', '/CN=RTL synthetic TLS fixture']; ++ if (expired) { ++ execFileSync('openssl', [...base, '-out', prefix + '.csr'], { stdio: 'ignore' }); ++ writeFileSync(prefix + '.index', ''); ++ writeFileSync(prefix + '.serial', '01\n'); ++ writeFileSync(prefix + '.cnf', `[ca] ++ default_ca = fixture ++[fixture] ++ database = ${prefix}.index ++ serial = ${prefix}.serial ++ new_certs_dir = ${root} ++ default_md = sha256 ++ policy = names ++ x509_extensions = extensions ++[names] ++ commonName = supplied ++[extensions] ++ subjectAltName = ${san} ++`); ++ execFileSync('openssl', ['ca', '-batch', '-selfsign', '-config', prefix + '.cnf', '-in', prefix + '.csr', '-keyfile', keyPath, '-startdate', '20000101000000Z', '-enddate', '20010101000000Z', '-out', certPath, '-notext'], { stdio: 'ignore' }); ++ } else { ++ execFileSync('openssl', [...base, '-x509', '-days', '2', '-addext', 'subjectAltName=' + san, '-out', certPath], { stdio: 'ignore' }); ++ } ++ return { keyPath, certPath, key: readFileSync(keyPath), cert: readFileSync(certPath) }; ++} ++ ++const pair = certificate(); ++export const tlsCertPath = pair.certPath; ++export const createServer = (handler) => httpsServer({ key: pair.key, cert: pair.cert }, handler); diff --git a/buildRtl/render-config.cjs b/buildRtl/render-config.cjs new file mode 100644 index 0000000..153b746 --- /dev/null +++ b/buildRtl/render-config.cjs @@ -0,0 +1,10 @@ +const fs = require('node:fs'); +const password = process.env.APP_PASSWORD; +if (!password || ['changeme', 'dappnode'].includes(password)) { + console.error('Set APP_PASSWORD to a non-default password before starting RTL.'); + process.exit(1); +} +const config = JSON.parse(fs.readFileSync('/opt/dappnode/RTL-Config.json', 'utf8')); +// APP_PASSWORD is consumed by RTL itself. Do not interpolate it into JSON or share it with LND. +fs.mkdirSync('/RTL/backup/node-1/restore', { recursive: true }); +fs.writeFileSync('/RTL/RTL-Config.json', JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); diff --git a/dappnode_package.json b/dappnode_package.json index d372366..8661b4b 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -1,7 +1,7 @@ { "name": "lightning-network.dnp.dappnode.eth", - "version": "0.4.4", - "upstreamVersion": "v0.14.1-beta", + "version": "0.5.0", + "upstreamVersion": "v0.21.4-beta", "shortDescription": "Scalable, instant Bitcoin transactions", "description": "The Lightning Network DAppNodePackage (lnd + RTL). The Lightning Network is a decentralized system for instant, high-volume micropayments that remove the risk of delegating custody of funds to trusted third parties. \n\n\n **Getting started** \n\n Go to the [Lightning Network DAppNode Package readme](https://github.com/dappnode/DAppNodePackage-LightningNetwork#readme) to learn how to get started and safely backup your data.", "type": "service", @@ -11,15 +11,18 @@ "backup": [ { "name": "rtl-channel", - "path": "/rtl/backup/" + "path": "/RTL/backup/", + "service": "rtl" }, { - "name": "lnd-channel", - "path": "/root/.lnd/data/chain/bitcoin/mainnet/channel.backup" + "name": "lnd-state", + "path": "/root/.lnd/", + "service": "lnd" }, { - "name": "wallet", - "path": "/root/.lnd/data/chain/bitcoin/mainnet/wallet.db" + "name": "lnd-shared-credentials", + "path": "/shared/", + "service": "lnd" } ], "style": { @@ -43,8 +46,8 @@ ], "links": { "homepage": "https://github.com/dappnode/DAppNodePackage-LightningNetwork#readme", - "ui": "http://lightning-network.dappnode", - "api": "http://lightning-network.dappnode:8080" + "ui": "http://rtl.lightning-network.dappnode", + "api": "https://lnd.lightning-network.dappnode:8080" }, "repository": { "type": "git", @@ -56,5 +59,9 @@ "disclaimer": { "message": "This software is experimental, presented 'as is' and inherently carries risks. By installing it, you acknowledge that DAppNode Association has done its best to mitigate these risks and accept to waive any liability or responsibility for DAppNode in case of any shortage, discrepancy, damage, loss or destruction of any digital asset managed within this DAppNode package.\n\nThis package stores private keys, which will be stored in your DAppNode. Neither DAppNode Association nor the developers of this software can have access to your private key, nor help you recover it if you lose it. \n\nYou are solely responsible for keeping your private keys and password safe and to perform secure backups, as well as to restrict access to your computer and other equipment. To the extent permitted by applicable law, you agree to be responsible for all activities that have been conducted from your account. You must take all necessary steps to ensure that your private key, password, and/or recovery phrase remain confidential and secured." }, - "license": "GPL-3.0" + "license": "GPL-3.0", + "mainService": "lnd", + "architectures": [ + "linux/amd64" + ] } diff --git a/docker-compose.yml b/docker-compose.yml index f92f40f..6f252e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,35 @@ version: "3.4" services: - lightning-network.dnp.dappnode.eth: - image: "lightning-network.dnp.dappnode.eth:0.4.4" - build: ./src + lnd: + image: "lnd.lightning-network.dnp.dappnode.eth:0.5.0" + build: ./buildLnd volumes: - "lndconfig_data:/root/.lnd/" - - "lndconfig_backup:/rtl/backup/" + - "lnd_shared:/shared" ports: - "9735:9735" environment: - - APP_PASSWORD=changeme - RPCUSER=dappnode - RPCPASS=dappnode - BITCOIND_HOST=bitcoin.dappnode + - BITCOIND_RPCPORT= + - BITCOIND_RPC_POLLING=false + - LND_MIGRATION_CONFIRMED=false - NETWORK=mainnet - ALIAS= - COLOR=#5ACDC5 - EXT_IP= restart: always + rtl: + image: "rtl.lightning-network.dnp.dappnode.eth:0.5.0" + build: ./buildRtl + volumes: + - "lnd_shared:/shared:ro" + - "lndconfig_backup:/RTL/backup/" + environment: + - APP_PASSWORD= + restart: always volumes: lndconfig_data: {} lndconfig_backup: {} + lnd_shared: {} diff --git a/setup-target.json b/setup-target.json deleted file mode 100644 index 873adb6..0000000 --- a/setup-target.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "rtl_password": { - "type": "environment", - "name": "APP_PASSWORD" - }, - "alias": { - "type": "environment", - "name": "ALIAS" - } -} diff --git a/setup-ui.json b/setup-ui.json deleted file mode 100644 index 7d0dd3b..0000000 --- a/setup-ui.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "ui:order": ["rtl_password","alias"], - "rtl_password": { - "ui:widget": "password" - } -} diff --git a/setup-wizard.yml b/setup-wizard.yml new file mode 100644 index 0000000..7b6749e --- /dev/null +++ b/setup-wizard.yml @@ -0,0 +1,20 @@ +version: "2" +fields: + - id: rtl_password + target: + type: environment + name: APP_PASSWORD + service: rtl + title: RTL password + description: Choose the password you will use to sign in to Ride The Lightning. + secret: true + required: true + pattern: '^(?!(?:changeme|dappnode)$).{8,}$' + patternErrorMessage: Use at least eight characters and avoid the default passwords. + - id: alias + target: + type: environment + name: ALIAS + service: lnd + title: Node alias + description: Your Lightning node alias is public. diff --git a/setup.schema.json b/setup.schema.json deleted file mode 100644 index ecd2aa2..0000000 --- a/setup.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "description": "# Want to run Lightning Network on DAppNode?\n\nGreat! That's probably why you are here.\n\nThis wizard will help you to configure your node", - "type": "object", - "properties": { - "rtl_password": { - "type": "string", - "title": "Password", - "description": "Your password for Ride The Lightning Web UI" - }, - "alias": { - "type": "string", - "title": "Alias", - "description": "Alias name for your node (will be public)" - } - }, - "required": ["rtl_password"] -} diff --git a/src/Dockerfile b/src/Dockerfile deleted file mode 100644 index 25d203d..0000000 --- a/src/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -FROM node:10.16.0-alpine as build - -ENV LND_VERSION "v0.14.1-beta" -ENV RTL_TAG "v0.12.1" - -RUN apk add --no-cache wget ca-certificates \ - && wget https://github.com/lightningnetwork/lnd/releases/download/${LND_VERSION}/lnd-linux-amd64-${LND_VERSION}.tar.gz \ - && tar --strip-components 1 -xzf lnd-linux-amd64-${LND_VERSION}.tar.gz -C /bin/ \ - && rm lnd-linux-amd64-${LND_VERSION}.tar.gz \ - && apk del wget ca-certificates - -# Add build requirements -RUN apk add --no-cache \ - bash curl openssl supervisor git make g++ - -RUN git clone -b $RTL_TAG https://github.com/Ride-The-Lightning/RTL.git /rtl - -WORKDIR /rtl - -RUN npm install -RUN rm -rf ./node_modules/* && npm install --only=production && npm install @types/node - -FROM alpine - -WORKDIR /rtl - -ENV CHANNEL_BACKUP_PATH /root/.lnd - -# Add requirements -RUN apk add --no-cache supervisor bash curl nodejs openssl gettext - -COPY --from=build /rtl /rtl -COPY --from=build /bin/lnd /bin/lnd -COPY --from=build /bin/lncli /bin/lncli - -COPY scripts/start-lnd.sh / -COPY scripts/start-rtl.sh /rtl -COPY config/source_RTL.conf /rtl -COPY config/source_RTL-Config.json /rtl - -RUN ln -s /root/.lnd /config - -RUN mkdir -p /var/log/supervisor -COPY config/supervisord.conf / - -# Expose lnd ports (server, rpc). -EXPOSE 9735 10009 80 - -CMD ["supervisord","-c","/supervisord.conf"] diff --git a/src/config/source_RTL.conf b/src/config/source_RTL.conf deleted file mode 100644 index d7922aa..0000000 --- a/src/config/source_RTL.conf +++ /dev/null @@ -1,21 +0,0 @@ -[Authentication] -enableLogging=true -macaroonPath=/config/ -nodeAuthType=CUSTOM -rtlPass=$RTL_PASSWORD - -[Settings] -flgSidenavOpened=true -flgSidenavPinned=true -menu=Vertical -menuType=Regular -theme=light-teal -satsToBTC=false -lndServerUrl=https://localhost:8080/v1 -enableLogging=true -port=80 - -[SSO] -rtlSSO=0 -rtlCookiePath= -logoutRedirectLink=/login diff --git a/src/config/supervisord.conf b/src/config/supervisord.conf deleted file mode 100644 index e4f915f..0000000 --- a/src/config/supervisord.conf +++ /dev/null @@ -1,33 +0,0 @@ -[supervisord] -nodaemon=true -user=root - -[unix_http_server] -file=/var/run/supervisor.sock -username=dummy -password=dummy - -[rpcinterface:supervisor] -supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface - -[supervisorctl] -serverurl=unix:///var/run/supervisor.sock -username=dummy -password=dummy - -[program:lnd] -command=bash -c '/start-lnd.sh' -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -startretries=10 - -[program:rtl] -command=bash -c '/rtl/start-rtl.sh' -directory=/rtl -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 -startretries=10 diff --git a/src/scripts/start-lnd.sh b/src/scripts/start-lnd.sh deleted file mode 100755 index 368c60f..0000000 --- a/src/scripts/start-lnd.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash - -# exit from script if error was raised. -set -e - -# error function is used within a bash function in order to send the error -# message directly to the stderr output and exit. -error() { - echo "$1" > /dev/stderr - exit 0 -} - -# return is used within bash function in order to return the value. -return() { - echo "$1" -} - -# set_default function gives the ability to move the setting of default -# env variable from docker file to the script thereby giving the ability to the -# user override it durin container start. -set_default() { - # docker initialized env variables with blank string and we can't just - # use -z flag as usually. - BLANK_STRING='""' - - VARIABLE="$1" - DEFAULT="$2" - - if [[ -z "$VARIABLE" || "$VARIABLE" == "$BLANK_STRING" ]]; then - - if [ -z "$DEFAULT" ]; then - error "You should specify default variable" - else - VARIABLE="$DEFAULT" - fi - fi - - return "$VARIABLE" -} - -# Generate P-256 curve certificates needed for lncli-web -if [ ! -f /$HOME/.lnd/tls.key ]; then - openssl ecparam -genkey -name prime256v1 -out tls.key - openssl req -new -sha256 -key tls.key -out csr.csr -subj '/CN=localhost/O=lnd' - openssl req -x509 -sha256 -days 36500 -key tls.key -in csr.csr -out tls.cert - rm csr.csr -fi - -if [ -z $EXT_IP ]; then - EXT_IP=$(curl -4s ident.me) -fi - -# Set default variables if needed. -BITCOIND_HOST=$(set_default "$BITCOIND_HOST" "127.0.0.1") -RPCUSER=$(set_default "$RPCUSER" "rpcuser") -RPCPASS=$(set_default "$RPCPASS" "rpcpass") -DEBUG=$(set_default "$DEBUG" "debug") -NETWORK=$(set_default "$NETWORK" "testnet") -CHAIN=$(set_default "$CHAIN" "bitcoin") -BACKEND="bitcoind" - -# Symlink for lncli-web -if [ ! -f /$HOME/.lnd/admin.macaroon ]; then - mkdir -p /$HOME/.lnd/data/chain/$CHAIN/$NETWORK/ - ln -s /$HOME/.lnd/admin.macaroon /$HOME/.lnd/data/chain/$CHAIN/$NETWORK/admin.macaroon -fi - -exec lnd \ ---noseedbackup \ ---logdir="/data" \ -"--$CHAIN.active" \ -"--$CHAIN.$NETWORK" \ -"--$CHAIN.node=bitcoind" \ -"--$BACKEND.rpchost=$BITCOIND_HOST" \ -"--$BACKEND.rpcuser=$RPCUSER" \ -"--$BACKEND.rpcpass=$RPCPASS" \ -"--$BACKEND.zmqpubrawblock=tcp://$BITCOIND_HOST:28332" \ -"--bitcoind.zmqpubrawtx=tcp://$BITCOIND_HOST:28333" \ -"--externalip=$EXT_IP" \ -"--alias=$ALIAS" \ -"--color=$COLOR" \ -"--restlisten=0.0.0.0:8080" \ ---debuglevel="$DEBUG" \ -"$@" diff --git a/src/scripts/start-rtl.sh b/src/scripts/start-rtl.sh deleted file mode 100755 index d34f4e7..0000000 --- a/src/scripts/start-rtl.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -e - -# Create restore symlink -if [ ! -L /rtl/backup/restore ]; then - ln -s /rtl/backup /rtl/backup/restore -fi - -# Configure settings from env vars -envsubst < "source_RTL-Config.json" > "RTL-Config.json" - -envsubst < "source_RTL.conf" > "RTL.conf" - -exec node rtl diff --git a/tests/Dockerfile.bitcoin-regtest b/tests/Dockerfile.bitcoin-regtest new file mode 100644 index 0000000..e7f2523 --- /dev/null +++ b/tests/Dockerfile.bitcoin-regtest @@ -0,0 +1,9 @@ +FROM debian:bookworm-slim +ARG BITCOIN_VERSION=31.1 +ARG BITCOIN_SHA256=b80d9c3e04da78fb6f0569685673418cf686fadba9042d926d13fb87ff503f9e +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl libstdc++6 \ + && curl --fail --location "https://bitcoincore.org/bin/bitcoin-core-${BITCOIN_VERSION}/bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz" -o /tmp/bitcoin.tar.gz \ + && echo "${BITCOIN_SHA256} /tmp/bitcoin.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/bitcoin.tar.gz --strip-components=2 -C /usr/local/bin "bitcoin-${BITCOIN_VERSION}/bin/bitcoind" "bitcoin-${BITCOIN_VERSION}/bin/bitcoin-cli" \ + && rm /tmp/bitcoin.tar.gz && rm -rf /var/lib/apt/lists/* +ENTRYPOINT ["bitcoind"] diff --git a/tests/Dockerfile.lnd-legacy b/tests/Dockerfile.lnd-legacy new file mode 100644 index 0000000..7c597c5 --- /dev/null +++ b/tests/Dockerfile.lnd-legacy @@ -0,0 +1,10 @@ +# Test-only: create an unfunded wallet using the package's previous LND release. +FROM node:22.23.4-alpine3.24 +ARG LND_VERSION=v0.14.1-beta +ARG LND_SHA256=7034e4aea3f404a9bbb1d157653741eb0e020c3d3272f854d3e2cefa0070f24a +RUN apk add --no-cache ca-certificates curl \ + && curl --fail --location "https://github.com/lightningnetwork/lnd/releases/download/${LND_VERSION}/lnd-linux-amd64-${LND_VERSION}.tar.gz" -o /tmp/lnd.tar.gz \ + && echo "${LND_SHA256} /tmp/lnd.tar.gz" | sha256sum -c - \ + && tar --strip-components=1 -xzf /tmp/lnd.tar.gz -C /usr/local/bin \ + && rm /tmp/lnd.tar.gz +ENTRYPOINT ["lnd"] diff --git a/tests/http-smoke.cjs b/tests/http-smoke.cjs new file mode 100644 index 0000000..6e731fa --- /dev/null +++ b/tests/http-smoke.cjs @@ -0,0 +1,5 @@ +const assert=require('node:assert/strict'),crypto=require('node:crypto');const base='http://127.0.0.1/rtl';let jar={}; +async function req(path,opts={}){const r=await fetch(base+path,{...opts,headers:{cookie:Object.entries(jar).map(([k,v])=>k+'='+v).join('; '),...opts.headers}});for(const c of r.headers.getSetCookie()){const x=c.split(';')[0],i=x.indexOf('=');jar[x.slice(0,i)]=x.slice(i+1);}return r;} +(async()=>{let r=await req('/login');assert.equal(r.status,200);const html=await r.text();assert(html.includes(''));const assets=[...html.matchAll(/(?:src|href)="([^\"]+\.(?:js|css))"/g)].map(m=>m[1]);for(const asset of assets){assert.equal((await req('/'+asset)).status,200);}assert(jar['XSRF-TOKEN']);assert.equal((await req('/api/lnd/getinfo')).status,401); +const auth={authenticateWith:'PASSWORD',authenticationValue:crypto.createHash('sha256').update('NodeTestRtlPassword20261023').digest('hex')};assert.equal((await req('/api/authenticate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(auth)})).status,403);const csrf=decodeURIComponent(jar['XSRF-TOKEN']);r=await req('/api/authenticate',{method:'POST',headers:{'content-type':'application/json','X-XSRF-TOKEN':csrf},body:JSON.stringify({...auth,authenticationValue:'bad'})});assert.equal(r.status,401);r=await req('/api/authenticate',{method:'POST',headers:{'content-type':'application/json','X-XSRF-TOKEN':csrf},body:JSON.stringify(auth)});assert.equal(r.status,200,await r.clone().text());const {token}=await r.json();assert(token);r=await req('/api/lnd/getinfo',{headers:{authorization:'Bearer '+token}});assert.equal(r.status,200,await r.clone().text());assert.equal((await r.json()).alias,process.env.EXPECTED_LND_ALIAS||'Node test fixture');r=await req('/api/conf',{headers:{authorization:'Bearer '+token}});assert.equal(r.status,200);const conf=await r.json();assert(conf); +const WS=require('/RTL/node_modules/ws');await new Promise((resolve,reject)=>{const ws=new WS('ws://127.0.0.1:80/rtl/api/ws',[token,'1'],{headers:{cookie:Object.entries(jar).map(([k,v])=>k+'='+v).join('; ')}});ws.on('open',()=>{ws.close();resolve();});ws.on('error',reject);});console.log(JSON.stringify({html:true,assets:assets.length,csrfRequired:true,wrongPasswordRejected:true,passwordLogin:true,authenticatedLndFixture:true,configRead:true,websocketUpgrade:true,success:true}));})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/mock-lnd.cjs b/tests/mock-lnd.cjs new file mode 100644 index 0000000..b54bdc4 --- /dev/null +++ b/tests/mock-lnd.cjs @@ -0,0 +1,18 @@ +// Synthetic read-only LND fixture. Its TLS key stays outside the volume mounted by RTL. +const https = require('node:https'), fs = require('node:fs'); +const context = () => ({ key: fs.readFileSync('/tmp/lnd-fixture.key'), cert: fs.readFileSync('/tmp/lnd-fixture.crt') }); +fs.writeFileSync('/shared/tls.cert', context().cert); +fs.writeFileSync('/shared/admin.macaroon', Buffer.from('deadbeef', 'hex')); +fs.writeFileSync('/shared/http-audit', ''); +const server = https.createServer(context(), (q, s) => { + fs.appendFileSync('/shared/http-audit', q.method + ' ' + q.url + '\n'); + s.setHeader('content-type', 'application/json'); + if (q.headers['grpc-metadata-macaroon'] !== 'deadbeef') { s.writeHead(401); return s.end('{}'); } + let result; + if (q.url === '/v1/getinfo') result = { identity_pubkey: '02' + 'ab'.repeat(32), alias: 'Node test fixture', version: '0.21.4-beta', num_active_channels: 0, num_peers: 0, synced_to_chain: false, synced_to_graph: false, chains: [{ chain: 'bitcoin', network: 'mainnet' }] }; + else if (q.url === '/v1/channels/backup') result = { single_chan_backups: { chan_backups: [] }, multi_chan_backup: { chan_points: [], multi_chan_backup: '' } }; + else if (q.url.startsWith('/v1/invoices')) result = { invoices: [] }; + else result = {}; + s.end(JSON.stringify(result)); +}).listen(8080, '0.0.0.0'); +process.on('SIGUSR2', () => server.setSecureContext(context())); diff --git a/tests/node-security-smoke.cjs b/tests/node-security-smoke.cjs new file mode 100644 index 0000000..0747226 --- /dev/null +++ b/tests/node-security-smoke.cjs @@ -0,0 +1,22 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const dns = require("node:dns"); +const { X509Certificate, createHash } = require("node:crypto"); +const { rootCertificates } = require("node:tls"); +const expected = process.argv[2] || "22.23.4"; +assert.equal(process.versions.node, expected); +assert.throws(() => dns.resolve4("example.com\0.invalid", () => {}), { code: "ERR_INVALID_ARG_VALUE" }); +const blocked = new Set([ + "bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3", + "c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9", + "8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782" +]); +const systemRoots = fs.readFileSync(process.argv[3] || "/etc/ssl/certs/ca-certificates.crt", "utf8") + .match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); +assert.ok(systemRoots.length > 100); +for (const pem of [...rootCertificates, ...systemRoots]) { + const key = new X509Certificate(pem).publicKey.export({ type: "spki", format: "der" }); + assert.ok(!blocked.has(createHash("sha256").update(key).digest("hex"))); +} +console.log(JSON.stringify({ node: process.versions.node, systemRoots: systemRoots.length, + nodeRoots: rootCertificates.length, dnsNulRejected: true, distrustedRootsAbsent: true })); diff --git a/tests/regtest-smoke.py b/tests/regtest-smoke.py new file mode 100644 index 0000000..1343d7f --- /dev/null +++ b/tests/regtest-smoke.py @@ -0,0 +1,169 @@ +"""Split-service wallet lifecycle on an internal network; no funded or production state.""" +import argparse +import base64 +import json +import pathlib +import subprocess +import time +import uuid + +p = argparse.ArgumentParser() +p.add_argument('--lnd-image', default='codex/lightning-lnd:split-20261023') +p.add_argument('--rtl-image', default='codex/lightning-rtl:split-20261023') +p.add_argument('--bitcoin-image', default='codex/bitcoin-regtest:31.1') +p.add_argument('--old-image', default='codex/lnd-legacy-regtest:0.14.1') +p.add_argument('--bridge-image') +p.add_argument('--fresh', action='store_true') +p.add_argument('--encrypted-tls', action='store_true') +p.add_argument('--output', required=True) +a = p.parse_args() +out = pathlib.Path(a.output); out.mkdir(parents=True, exist_ok=True) +tests = pathlib.Path(__file__).resolve().parent +uid = 'lightning-split-regtest-' + uuid.uuid4().hex[:10] +btc, old, lnd, rtl, data, shared, backup = [uid + x for x in ['-btc', '-old', '-lnd', '-rtl', '-data', '-shared', '-backup']] +password = base64.b64encode(b'Only-An-Unfunded-Regtest-Wallet-2026').decode() +result = {} + +# This test client trusts only public files in read-only mounts, never an unverified peer. +REST_CLIENT = """ +const fs=require('fs'),https=require('https'); +const input=JSON.parse(fs.readFileSync(0,'utf8')); +const ca=input.paths.filter(p=>fs.existsSync(p)).map(p=>fs.readFileSync(p)); +if(!ca.length)process.exit(1); +const req=https.request(input.url,{ca,rejectUnauthorized:true,method:input.body?'POST':'GET',headers:{'content-type':'application/json'}},res=>{ + let body='';res.on('data',c=>body+=c);res.on('end',()=>{if(res.statusCode>=400)process.exit(1);process.stdout.write(body)}); +});req.on('error',()=>process.exit(1));req.setTimeout(5000,()=>req.destroy());if(input.body)req.write(JSON.stringify(input.body));req.end(); +""" + +def run(*args, check=True, input=None): + r = subprocess.run(['docker', *args], capture_output=True, text=True, input=input, timeout=180) + if check and r.returncode: + # stdout from the seed endpoint must never be included in a diagnostic. + raise RuntimeError('Docker ' + args[0] + ' failed\n' + r.stderr[-2000:]) + return r.stdout.strip() + +def wait(fn, timeout=70): + deadline = time.monotonic() + timeout + while True: + try: return fn() + except Exception: + if time.monotonic() >= deadline: raise + time.sleep(.4) + +def btc_rpc(*args): + value = run('exec', btc, 'bitcoin-cli', '-regtest', '-rpcuser=test', '-rpcpassword=fixture', *args) + try: return json.loads(value) + except json.JSONDecodeError: return value + +def rest(name, path, body=None): + prefix = '/shared' if name == lnd else '/lnd' + host = 'lnd.lightning-network.dappnode' if name == lnd else name + value = run('run', '--rm', '-i', '--network', uid, '-v', data + ':/lnd:ro', '-v', shared + ':/shared:ro', + '--entrypoint', 'node', a.rtl_image, '-e', REST_CLIENT, + input=json.dumps({'url': 'https://' + host + ':8080' + path, 'paths': [prefix + '/tls.cert', prefix + '/tls.cert.tmp'], 'body': body})) + return json.loads(value) + +def cli(name, *args): + options = ['--tlscertpath=/shared/tls.cert', '--macaroonpath=/shared/admin.macaroon'] if name == lnd else [] + return json.loads(run('exec', name, 'lncli', '--network=regtest', *options, *args)) + +def rtl_ready(): + return run('exec', rtl, 'node', '-e', "fetch('http://127.0.0.1/rtl/login').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))") + +def wallet_operation(operation): + wait(rtl_ready); wait(lambda: rest(lnd, '/v1/state')) + temporary = run('exec', lnd, 'sh', '-c', 'test -s /shared/tls.cert.tmp && echo present || echo absent') == 'present' + if a.encrypted_tls: assert temporary + response = json.loads(run('exec', '-i', rtl, 'node', '/tmp/rtl-wallet-unlock.cjs', operation, input=password)) + if a.encrypted_tls: + wait(lambda: run('exec', lnd, 'sh', '-c', 'test -s /shared/tls.cert && test ! -e /shared/tls.cert.tmp')) + response['temporaryCertificateBeforeOperation'] = temporary + response['permanentCertificateAfterOperation'] = True + return response + +def http_smoke(): + return json.loads(run('exec', '-e', 'EXPECTED_LND_ALIAS=UnfundedRegtest', rtl, 'node', '/tmp/http-smoke.cjs')) + +def start_legacy(image): + run('run', '-d', '--name', old, '--network', uid, '--cpus', '2', '--memory', '1g', + '-v', data + ':/root/.lnd', '--entrypoint', 'lnd', image, + '--bitcoin.active', '--bitcoin.regtest', '--bitcoin.node=bitcoind', + '--bitcoind.rpchost=bitcoin-fixture:18443', '--bitcoind.rpcuser=test', '--bitcoind.rpcpass=fixture', + '--bitcoind.zmqpubrawblock=tcp://bitcoin-fixture:28332', '--bitcoind.zmqpubrawtx=tcp://bitcoin-fixture:28333', + '--restlisten=0.0.0.0:8080', '--tlsextradomain=' + old, '--alias=UnfundedRegtest') + +try: + assert a.fresh or a.bridge_image, 'Legacy state requires the documented intermediate version.' + assert not a.encrypted_tls or a.fresh + run('network', 'create', '--internal', uid) + for volume in [data, shared, backup]: run('volume', 'create', volume) + if a.encrypted_tls: + run('run', '--rm', '--network', 'none', '-v', data + ':/root/.lnd', '--entrypoint', 'sh', a.lnd_image, + '-c', 'printf "[Application Options]\\ntlsencryptkey=true\\n" > /root/.lnd/lnd.conf') + run('run', '-d', '--name', btc, '--network', uid, '--network-alias', 'bitcoin-fixture', + '--cpus', '2', '--memory', '1g', a.bitcoin_image, '-regtest', '-server', '-listen=0', + '-rpcbind=0.0.0.0', '-rpcallowip=0.0.0.0/0', '-rpcuser=test', '-rpcpassword=fixture', '-fallbackfee=0.0002', + '-zmqpubrawblock=tcp://0.0.0.0:28332', '-zmqpubrawtx=tcp://0.0.0.0:28333') + wait(lambda: btc_rpc('getblockchaininfo')) + bitcoin_version = btc_rpc('getnetworkinfo')['version'] + btc_rpc('-named', 'createwallet', 'wallet_name=fixture') + btc_rpc('generatetoaddress', '101', btc_rpc('getnewaddress')) + before = None; bridge_info = None + if not a.fresh: + start_legacy(a.old_image) + seed = wait(lambda: rest(old, '/v1/genseed'))['cipher_seed_mnemonic'] + rest(old, '/v1/initwallet', {'wallet_password': password, 'cipher_seed_mnemonic': seed}); del seed + before = wait(lambda: cli(old, 'getinfo')) + assert before['version'].startswith('0.14.1') + run('stop', '--time', '30', old); run('rm', old) + start_legacy(a.bridge_image) + wait(lambda: rest(old, '/v1/unlockwallet', {'wallet_password': password})) + bridge_info = wait(lambda: cli(old, 'getinfo')) + assert bridge_info['identity_pubkey'] == before['identity_pubkey'] + run('stop', '--time', '30', old); run('rm', old) + run('run', '-d', '--name', lnd, '--network', uid, '--network-alias', 'lnd.lightning-network.dappnode', + '--cpus', '2', '--memory', '1g', '-v', data + ':/root/.lnd', '-v', shared + ':/shared', + '-e', 'NETWORK=regtest', '-e', 'BITCOIND_HOST=bitcoin-fixture', '-e', 'BITCOIND_RPCPORT=18443', + '-e', 'BITCOIND_RPC_POLLING=true', '-e', 'RPCUSER=test', '-e', 'RPCPASS=fixture', + '-e', 'LND_MIGRATION_CONFIRMED=true', '-e', 'ALIAS=UnfundedRegtest', a.lnd_image) + run('run', '-d', '--name', rtl, '--network', uid, '--cpus', '2', '--memory', '1g', + '-v', shared + ':/shared:ro', '-v', backup + ':/RTL/backup', + '-e', 'APP_PASSWORD=NodeTestRtlPassword20261023', a.rtl_image) + for filename in ['rtl-wallet-unlock.cjs', 'http-smoke.cjs']: + run('cp', str(tests / filename), rtl + ':/tmp/' + filename) + operation = wallet_operation('init' if a.fresh else 'unlock') + after = wait(lambda: cli(lnd, 'getinfo')) + if before is None: before = after + assert after['identity_pubkey'] == before['identity_pubkey'] + assert after['version'].startswith('0.21.4') and after['chains'][0]['network'] == 'regtest' + assert cli(lnd, 'newaddress', 'p2wkh')['address'].startswith('bcrt') + assert cli(lnd, 'addinvoice', '--amt', '1', '--memo', 'isolated-split-test')['payment_request'].startswith('lnbcrt') + http = http_smoke() + isolation = json.loads(run('exec', rtl, 'node', '-e', "const fs=require('fs'),assert=require('assert');assert(!fs.existsSync('/root/.lnd/tls.key'));assert(!fs.existsSync('/shared/tls.key'));assert.throws(()=>fs.writeFileSync('/shared/test','x'));console.log(JSON.stringify({sharedReadOnly:true,walletAndPrivateKeyAbsent:true}))")) + lnd_env = json.loads(run('inspect', lnd))[0]['Config']['Env'] + assert not any(value.startswith('APP_PASSWORD=') for value in lnd_env) + run('restart', rtl); wait(rtl_ready) + rtl_restart = http_smoke() + # Restart LND alone: RTL must reread temporary/current public certificates without its own restart. + run('restart', '--time', '30', lnd) + unlock_restart = wallet_operation('unlock') + restarted = wait(lambda: cli(lnd, 'getinfo')) + assert restarted['identity_pubkey'] == before['identity_pubkey'] + assert int(cli(lnd, 'walletbalance')['total_balance']) == 0 + after_lnd_restart = http_smoke() + result = {'success': True, 'lndVersion': after['version'], 'bitcoinVersion': bitcoin_version, + 'freshInstallTest': a.fresh, 'encryptedTlsKey': a.encrypted_tls, 'rtlWalletOperation': operation, + 'intermediateRelease': bridge_info['version'] if bridge_info else None, + 'legacyDatabaseMigrated': not a.fresh, 'nodeIdentityPreserved': True, + 'rtlRealLnd': http, 'rtlRestart': rtl_restart, 'lndRestartRtlUnlock': unlock_restart, + 'rtlAfterLndRestart': after_lnd_restart, 'isolation': isolation, + 'uiPasswordAbsentFromLnd': True, 'walletBalanceSatoshis': 0, + 'externalPeers': False, 'paymentsSent': False, 'productionDataUsed': False} +finally: + for name in [old, lnd, rtl, btc]: + (out / (name.rsplit('-', 1)[-1] + '-private.log')).write_text(run('logs', name, check=False)) + run('rm', '-f', '-v', name, check=False) + for volume in [data, shared, backup]: run('volume', 'rm', volume, check=False) + run('network', 'rm', uid, check=False) + (out / 'regtest-validation.json').write_text(json.dumps(result, indent=2) + '\n') +print(json.dumps(result)) diff --git a/tests/rtl-wallet-unlock.cjs b/tests/rtl-wallet-unlock.cjs new file mode 100644 index 0000000..cc4adc1 --- /dev/null +++ b/tests/rtl-wallet-unlock.cjs @@ -0,0 +1,41 @@ +// Disposable regtest wallet only. Read the synthetic password from stdin; never print it. +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const base = 'http://127.0.0.1/rtl'; +const jar = {}; +async function req(path, options = {}) { + const response = await fetch(base + path, { ...options, headers: { + cookie: Object.entries(jar).map(([k, v]) => k + '=' + v).join('; '), ...options.headers + } }); + for (const line of response.headers.getSetCookie()) { + const pair = line.split(';')[0], i = pair.indexOf('='); jar[pair.slice(0, i)] = pair.slice(i + 1); + } + return response; +} +(async () => { + const password = fs.readFileSync(0, 'utf8').trim(); + assert.equal((await req('/login')).status, 200); + const login = await req('/api/authenticate', { method: 'POST', headers: { + 'content-type': 'application/json', 'x-xsrf-token': decodeURIComponent(jar['XSRF-TOKEN']) + }, body: JSON.stringify({ authenticateWith: 'PASSWORD', authenticationValue: crypto.createHash('sha256').update('NodeTestRtlPassword20261023').digest('hex') }) }); + assert.equal(login.status, 200); + const { token } = await login.json(); + // Match the UI's node-option initialization before making a wallet request. + const setup = await req('/api/lnd/wallet/updateSelNodeOptions', { headers: { authorization: 'Bearer ' + token } }); + assert.ok([200, 502].includes(setup.status)); + const initializing = process.argv[2] === 'init'; + const wallet = { wallet_password: password }; + if (initializing) { + const seed = await req('/api/lnd/wallet/genseed', { method: 'POST', headers: { + 'content-type': 'application/json', 'x-xsrf-token': decodeURIComponent(jar['XSRF-TOKEN']), authorization: 'Bearer ' + token + }, body: '{}' }); + assert.equal(seed.status, 200, 'RTL seed generation failed'); + wallet.cipher_seed_mnemonic = (await seed.json()).cipher_seed_mnemonic; + } + const response = await req('/api/lnd/wallet/wallet/' + (initializing ? 'initwallet' : 'unlockwallet'), { method: 'POST', headers: { + 'content-type': 'application/json', 'x-xsrf-token': decodeURIComponent(jar['XSRF-TOKEN']), authorization: 'Bearer ' + token + }, body: JSON.stringify(wallet) }); + assert.equal(response.status, 201, 'RTL wallet unlock failed'); + console.log(JSON.stringify({ operation: initializing ? 'init' : 'unlock', authenticatedRtlWalletOperation: true, verifiedLndTransport: true })); +})().catch(error => { console.error(error); process.exit(1); }); diff --git a/tests/runtime-smoke.py b/tests/runtime-smoke.py new file mode 100644 index 0000000..dc161e6 --- /dev/null +++ b/tests/runtime-smoke.py @@ -0,0 +1,128 @@ +"""Packaged split-service UI/TLS checks. Disposable volumes and synthetic credentials only.""" +import argparse +import json +import pathlib +import subprocess +import tempfile +import time +import uuid + +p = argparse.ArgumentParser() +p.add_argument('--rtl-image', default='codex/lightning-rtl:split-20261023') +p.add_argument('--lnd-image', default='codex/lightning-lnd:split-20261023') +p.add_argument('--output', required=True) +a = p.parse_args() +out = pathlib.Path(a.output); out.mkdir(parents=True, exist_ok=True) +tests = pathlib.Path(__file__).resolve().parent +uid = 'lightning-split-test-' + uuid.uuid4().hex[:10] +rtl, mock, shared, backup = [uid + suffix for suffix in ['-rtl', '-mock', '-shared', '-backup']] +result = {} + +def run(*args, check=True, input=None): + r = subprocess.run(['docker', *args], input=input, capture_output=True, text=True, timeout=180) + if check and r.returncode: + raise RuntimeError('Docker ' + args[0] + ' failed\n' + r.stdout + r.stderr) + return r.stdout.strip() + +def wait(fn): + deadline = time.monotonic() + 45 + while True: + try: return fn() + except Exception: + if time.monotonic() >= deadline: raise + time.sleep(.3) + +def fixture_cert(directory, name, san): + prefix = pathlib.Path(directory) / name + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', + '-keyout', str(prefix) + '.key', '-out', str(prefix) + '.crt', '-days', '1', + '-subj', '/CN=Synthetic split-service fixture', '-addext', 'subjectAltName=' + san], + check=True, capture_output=True) + return prefix + +def write_trust(value): + script = "const fs=require('fs');const data=fs.readFileSync(0);if(data.length)fs.writeFileSync('/shared/tls.cert',data);else fs.unlinkSync('/shared/tls.cert');" + run('exec', '-i', mock, 'node', '-e', script, input=value) + +def audit(): + return run('exec', mock, 'node', '-e', "process.stdout.write(require('fs').readFileSync('/shared/http-audit','utf8'))") + +def api(mode): + return json.loads(run('exec', rtl, 'node', '/tmp/tls-runtime.cjs', mode)) + +try: + guard = subprocess.run(['docker', 'run', '--rm', '--network', 'none', '--cpus', '1', '--memory', '256m', + '--entrypoint', 'sh', a.lnd_image, '-c', + 'mkdir -p /root/.lnd/data/chain/bitcoin/mainnet; touch /root/.lnd/data/chain/bitcoin/mainnet/wallet.db; exec /opt/dappnode/entrypoint.sh'], + capture_output=True, text=True, timeout=15) + assert guard.returncode == 1 and 'staged upgrade' in guard.stderr + result['legacyMigrationGuard'] = True + guard = subprocess.run(['docker', 'run', '--rm', '--network', 'none', '--cpus', '1', '--memory', '256m', + '-e', 'APP_PASSWORD=changeme', a.rtl_image], capture_output=True, text=True, timeout=15) + assert guard.returncode == 1 and 'non-default password' in guard.stderr + result['defaultPasswordRejected'] = True + run('network', 'create', '--internal', uid) + for volume in [shared, backup]: run('volume', 'create', volume) + with tempfile.TemporaryDirectory(prefix='split-tls-fixtures-', dir=out) as directory: + good = fixture_cert(directory, 'good', 'DNS:lnd.lightning-network.dappnode,DNS:lnd,DNS:localhost') + impostor = fixture_cert(directory, 'impostor', 'DNS:lnd.lightning-network.dappnode') + wrong_name = fixture_cert(directory, 'wrong-name', 'DNS:localhost') + run('create', '--name', mock, '--network', uid, '--network-alias', 'lnd.lightning-network.dappnode', + '--cpus', '1', '--memory', '256m', '-v', shared + ':/shared', '--entrypoint', 'node', + a.rtl_image, '/tmp/mock-lnd.cjs') + run('cp', str(tests / 'mock-lnd.cjs'), mock + ':/tmp/mock-lnd.cjs') + for extension in ['key', 'crt']: run('cp', str(good) + '.' + extension, mock + ':/tmp/lnd-fixture.' + extension) + run('start', mock) + wait(lambda: run('exec', mock, 'node', '-e', "if(!require('fs').existsSync('/shared/tls.cert'))process.exit(1)")) + run('run', '-d', '--name', rtl, '--network', uid, '--cpus', '2', '--memory', '1g', + '-v', shared + ':/shared:ro', '-v', backup + ':/RTL/backup', + '-e', 'APP_PASSWORD=NodeTestRtlPassword20261023', a.rtl_image) + for filename in ['http-smoke.cjs', 'node-security-smoke.cjs', 'tls-runtime.cjs']: + run('cp', str(tests / filename), rtl + ':/tmp/' + filename) + wait(lambda: run('exec', rtl, 'node', '-e', "fetch('http://127.0.0.1/rtl/login').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))")) + result['nodeSecurity'] = json.loads(run('exec', rtl, 'node', '/tmp/node-security-smoke.cjs')) + result['http'] = json.loads(run('exec', rtl, 'node', '/tmp/http-smoke.cjs')) + result['isolation'] = api('pass') + for name, value in [('missing', ''), ('malformed', 'not a certificate'), ('untrusted', pathlib.Path(str(impostor) + '.crt').read_text())]: + before = audit() + write_trust(value) + result[name] = api('fail') + assert audit() == before, 'A failed TLS handshake delivered an HTTP request' + write_trust(pathlib.Path(str(good) + '.crt').read_text()) + api('pass') + for prefix, case in [(impostor, 'renewal'), (wrong_name, 'wrongHostname')]: + for extension in ['key', 'crt']: run('cp', str(prefix) + '.' + extension, mock + ':/tmp/lnd-fixture.' + extension) + run('kill', '--signal', 'USR2', mock); time.sleep(.1) + before = audit() + api('fail') + assert audit() == before + write_trust(pathlib.Path(str(prefix) + '.crt').read_text()) + if case == 'wrongHostname': + api('fail'); assert audit() == before + else: api('pass') + result[case] = True + for extension in ['key', 'crt']: run('cp', str(good) + '.' + extension, mock + ':/tmp/lnd-fixture.' + extension) + run('kill', '--signal', 'USR2', mock) + write_trust(pathlib.Path(str(good) + '.crt').read_text()) + run('restart', rtl) + wait(lambda: run('exec', rtl, 'node', '-e', "fetch('http://127.0.0.1/rtl/login').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))")) + result['restart'] = json.loads(run('exec', rtl, 'node', '/tmp/http-smoke.cjs')) + # Check the actual LND image's CA bundle using the same distrust regression. + ca_container = uid + '-ca' + run('create', '--name', ca_container, a.lnd_image) + try: + ca_file = pathlib.Path(directory) / 'lnd-ca.crt' + run('cp', ca_container + ':/etc/ssl/certs/ca-certificates.crt', str(ca_file)) + run('cp', str(ca_file), rtl + ':/tmp/lnd-ca.crt') + result['lndCaSecurity'] = json.loads(run('exec', rtl, 'node', '/tmp/node-security-smoke.cjs', '22.23.4', '/tmp/lnd-ca.crt')) + finally: run('rm', '-v', ca_container, check=False) + result.update(success=True, readOnlySharedCredentials=True, privateWalletAndTlsKeyNotMounted=True, + crossServiceHostnameVerified=True, backupPreserved=True, productionDataUsed=False) +finally: + for name in [rtl, mock]: + (out / (name.rsplit('-', 1)[-1] + '.log')).write_text(run('logs', name, check=False)) + run('rm', '-f', '-v', name, check=False) + for volume in [shared, backup]: run('volume', 'rm', volume, check=False) + run('network', 'rm', uid, check=False) + (out / 'runtime-validation.json').write_text(json.dumps(result, indent=2) + '\n') +print(json.dumps(result)) diff --git a/tests/tls-runtime.cjs b/tests/tls-runtime.cjs new file mode 100644 index 0000000..5a4e280 --- /dev/null +++ b/tests/tls-runtime.cjs @@ -0,0 +1,40 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const base = 'http://127.0.0.1/rtl'; +const jar = {}; +async function req(path, options = {}) { + const response = await fetch(base + path, { ...options, headers: { + cookie: Object.entries(jar).map(([k, v]) => k + '=' + v).join('; '), ...options.headers + } }); + for (const line of response.headers.getSetCookie()) { + const pair = line.split(';')[0], i = pair.indexOf('='); jar[pair.slice(0, i)] = pair.slice(i + 1); + } + return response; +} +(async () => { + assert.equal((await req('/login')).status, 200); + const login = await req('/api/authenticate', { method: 'POST', headers: { + 'content-type': 'application/json', 'x-xsrf-token': decodeURIComponent(jar['XSRF-TOKEN']) + }, body: JSON.stringify({ authenticateWith: 'PASSWORD', authenticationValue: crypto.createHash('sha256').update('NodeTestRtlPassword20261023').digest('hex') }) }); + assert.equal(login.status, 200); + const { token } = await login.json(); + const headers = { authorization: 'Bearer ' + token }; + const path = '/RTL/backup/node-1/channel-all.bak'; + if (process.argv[2] === 'fail') { + const backup = fs.readFileSync(path); + const response = await req('/api/lnd/getinfo', { headers }); + assert.ok(response.status >= 400); + const body = await response.text(); + assert.ok(!body.includes('deadbeef') && !body.includes('PRIVATE KEY')); + assert.equal((await req('/login')).status, 200); + assert.deepEqual(fs.readFileSync(path), backup); + console.log(JSON.stringify({ certificateErrorRejected: true, uiAvailable: true, backupPreserved: true })); + } else { + assert.equal((await req('/api/lnd/getinfo', { headers })).status, 200); + assert.throws(() => fs.writeFileSync('/shared/cannot-write', 'test'), { code: 'EROFS' }); + assert.ok(!fs.readFileSync('/RTL/RTL-Config.json', 'utf8').includes(process.env.APP_PASSWORD)); + for (const file of ['/root/.lnd/tls.key', '/root/.lnd/data/chain/bitcoin/regtest/wallet.db', '/shared/tls.key', '/shared/wallet.db']) assert.equal(fs.existsSync(file), false); + console.log(JSON.stringify({ verifiedConnection: true, sharedVolumeReadOnly: true, privateStateAbsent: true })); + } +})().catch(error => { console.error(error); process.exit(1); });