Add a Core Lightning node to the regtest docker fixture

The fixture had only bitcoind + three LND nodes, so RTL's Core Lightning screens
had no backend to exercise. Add a `cln` node (official elementsproject/lightningd
image, multi-arch) wired to RTL over clnrest with rune auth, and have the seed
open a cln->alice channel so the CLN channel/peer screens have real data.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saubyk 2026-07-17 10:03:34 -07:00 committed by Suheb
parent 80af65d7fe
commit b790dc7abf
6 changed files with 186 additions and 14 deletions

View file

@ -3,17 +3,22 @@
### NOT suitable for production. Development only. Every credential here is throwaway.
A self-contained regtest network for developing and testing RTL: `bitcoind`, three
LND nodes, and RTL wired to all three.
LND nodes, a Core Lightning node, and RTL wired to all four.
```
alice --[ 5,000,000 sat ]--> bob --[ 3,000,000 sat ]--> carol
cln --[ 4,000,000 sat ]--> alice
```
bob sits in the middle so it accrues forwarding history, which is what gives RTL's
routing screens something to show. Two nodes would leave them empty.
routing screens something to show. Two nodes would leave them empty. The `cln`
(Core Lightning) node gives RTL's CLN screens a real backend — it talks to RTL over
clnrest with rune auth.
Node images come from [Polar](https://lightningpolar.com), which publishes multi-arch
(amd64 + arm64) builds. Nothing is built locally, so this works on Apple Silicon.
LND and bitcoind images come from [Polar](https://lightningpolar.com); the Core
Lightning image is the official [`elementsproject/lightningd`](https://hub.docker.com/r/elementsproject/lightningd).
All are multi-arch (amd64 + arm64) and nothing is built locally, so this works on
Apple Silicon.
## Requirements
@ -24,12 +29,12 @@ Docker with Compose v2 (`docker compose`, not `docker-compose`).
From this directory:
```bash
docker compose up -d # bitcoind, alice, bob, carol, rtl
docker compose up -d # bitcoind, alice, bob, carol, cln, rtl
./scripts/seed.sh # fund, connect, open channels, make payments
```
Then open <http://localhost:3000> — password `rtldev`. All three nodes appear in
the node switcher.
Then open <http://localhost:3000> — password `rtldev`. All four nodes (alice, bob,
carol, cln) appear in the node switcher.
Tear down, discarding all state:
@ -41,12 +46,12 @@ docker compose down -v
| | |
|---|---|
| On-chain | 10,000,000 sats per node, confirmed |
| Channels | alice→bob 5,000,000 sats · bob→carol 3,000,000 sats (1,000,000 pushed each) |
| On-chain | 10,000,000 sats per node (LND) + 10,000,000 sats on cln, confirmed |
| Channels | alice→bob 5,000,000 sats · bob→carol 3,000,000 sats (1,000,000 pushed each) · cln→alice 4,000,000 sats |
| Routed payments | 5 × alice→carol via bob (10k, 25k, 50k, 75k, 100k sats) |
| Direct payments | 2 × alice→bob (5k, 15k sats) |
| Open invoices | 2 unpaid on carol (20k, 40k sats) |
| Personas | alice + bob OPERATOR, carol MERCHANT |
| Personas | alice + bob + cln OPERATOR, carol MERCHANT |
## Determinism
@ -70,6 +75,7 @@ bin/b-cli -rpcwallet=rtldev getbalance
bin/ln-cli alice getinfo # lncli, node name required
bin/ln-cli bob listchannels
bin/ln-cli bob fwdinghistory # forwarding history
docker compose exec cln lightning-cli --network=regtest listpeerchannels # Core Lightning
```
Logs:
@ -108,8 +114,16 @@ In `docker-compose.yml` the `$` must be written `$$` to escape Compose interpola
before she can route to carol. The seed waits for this; anything you script yourself
should too.
**Core Lightning auth uses a rune.** RTL talks to `cln` over clnrest and authenticates
with a rune, not a macaroon. On first start `cln/poststart.d/create-rune.sh` runs inside
the node (once the RPC is up), creates a master rune, and writes it as
`LIGHTNING_RUNE="…"` to `rtl.rune` in the shared `cln_data` volume; RTL reads it via the
`runePath` in its config. The `cln` healthcheck only passes once that file exists, so RTL
waits for it. `--clnrest-host=0.0.0.0` is required for RTL (another container) to reach
clnrest; the default `127.0.0.1` would only be reachable from inside the node.
## Not included
Core Lightning and Eclair nodes, and the Boltz swap service. Polar publishes
multi-arch `clightning` and `eclair` images, so adding them means compose services,
RTL config entries, and seeding adapters — no image building.
Eclair nodes and the Boltz swap service. Polar publishes a multi-arch `eclair` image, so
adding an Eclair node means a compose service, an RTL config entry, and a seeding adapter
— no image building.

View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# Runs from Core Lightning's lightning-poststart.d. Creates a master rune (once)
# and stores it in the format RTL expects: a file containing
# LIGHTNING_RUNE="<rune>"
# which RTL reads via its runePath.
#
# The image entrypoint can invoke poststart scripts before the RPC socket is
# ready (it watches the datadir with a race that loses on a fresh node), so poll
# for `getinfo` before calling createrune. Idempotent: keeps the same rune across
# restarts so RTL's stored auth stays valid.
RUNE_FILE="${LIGHTNINGD_DATA}/rtl.rune"
[ -f "${RUNE_FILE}" ] && exit 0
# Wait (up to ~120s) for the RPC to accept commands.
for _ in $(seq 1 120); do
if lightning-cli --network="${LIGHTNINGD_NETWORK}" getinfo >/dev/null 2>&1; then
break
fi
sleep 1
done
rune=$(lightning-cli --network="${LIGHTNINGD_NETWORK}" createrune 2>/dev/null \
| grep -o '"rune"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed -e 's/.*"rune"[[:space:]]*:[[:space:]]*"//' -e 's/"$//')
if [ -z "${rune}" ]; then
echo "create-rune.sh: failed to create rune" >&2
exit 1
fi
printf 'LIGHTNING_RUNE="%s"\n' "${rune}" > "${RUNE_FILE}"
echo "create-rune.sh: wrote rune to ${RUNE_FILE}"

View file

@ -13,6 +13,7 @@ volumes:
alice_data:
bob_data:
carol_data:
cln_data:
rtl_db:
rtl_config:
@ -142,6 +143,47 @@ services:
volumes:
- carol_data:/home/lnd/.lnd
# Core Lightning node. Unlike the LND nodes it talks to RTL over clnrest (the
# built-in REST plugin) using rune auth, so it needs --clnrest-* options and a
# rune written where RTL can read it. The entrypoint runs scripts dropped in
# lightning-poststart.d once the RPC socket is up; create-rune.sh generates the
# rune there and writes it to /root/.lightning/rtl.rune (RTL mounts that read-only).
# --clnrest-host=0.0.0.0 is required so the rtl container can reach it; the default
# 127.0.0.1 would only be reachable from inside this container. Protocol stays https
# (clnrest default, self-signed) — RTL connects with rejectUnauthorized:false.
cln:
image: elementsproject/lightningd:v25.09
container_name: ${COMPOSE_PROJECT_NAME}_cln
restart: unless-stopped
depends_on:
- bitcoind
environment:
LIGHTNINGD_NETWORK: regtest
command:
- --alias=cln
- --bitcoin-rpcconnect=${BITCOIN_HOST}
- --bitcoin-rpcport=${BITCOIN_RPC_PORT}
- --bitcoin-rpcuser=${BITCOIN_RPC_USER}
- --bitcoin-rpcpassword=${BITCOIN_RPC_PASSWORD}
- --bitcoin-retry-timeout=3600
- --addr=0.0.0.0:9735
- --announce-addr=cln:9735
- --large-channels
- --clnrest-host=0.0.0.0
- --clnrest-port=3010
ports:
- "${CLN_REST_PORT:-3010}:3010"
volumes:
- cln_data:/root/.lightning
- ./cln/poststart.d:/root/.lightning/lightning-poststart.d:ro
healthcheck:
# Healthy only once the RPC is up AND the rune file has been written, so the
# rtl service (which reads the rune at startup) can wait on this.
test: ["CMD-SHELL", "lightning-cli --network=regtest getinfo >/dev/null 2>&1 && test -f /root/.lightning/rtl.rune"]
interval: 5s
timeout: 5s
retries: 40
# RTL rewrites its config file on startup, so it cannot be given the tracked
# template directly: a bind mount would either be read-only (RTL exits with
# EROFS) or would let RTL scribble into a version-controlled file. Instead the
@ -160,7 +202,9 @@ services:
rtl:
container_name: ${COMPOSE_PROJECT_NAME}_rtl
image: shahanafarooqui/rtl:v0.15.8
# Defaults to the published image; override with RTL_IMAGE (e.g. a locally built
# branch image) to test unreleased changes: RTL_IMAGE=rtl:pr1625 docker compose up.
image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.8}
restart: unless-stopped
depends_on:
rtl-config-init:
@ -171,6 +215,8 @@ services:
condition: service_started
carol:
condition: service_started
cln:
condition: service_healthy
ports:
- "${RTL_PORT}:${RTL_PORT}"
environment:
@ -180,4 +226,5 @@ services:
- alice_data:/lnd/alice:ro
- bob_data:/lnd/bob:ro
- carol_data:/lnd/carol:ro
- cln_data:/cln:ro
- rtl_db:/RTL/database

View file

@ -62,6 +62,24 @@
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
},
{
"index": 4,
"lnNode": "cln",
"lnImplementation": "CLN",
"authentication": {
"runePath": "/cln/rtl.rune"
},
"settings": {
"userPersona": "OPERATOR",
"themeMode": "DAY",
"themeColor": "PURPLE",
"logLevel": "ERROR",
"lnServerUrl": "https://cln:3010",
"fiatConversion": false,
"unannouncedChannels": false,
"blockExplorerUrl": "https://mempool.space"
}
}
]
}

View file

@ -38,6 +38,7 @@ NODES=(alice bob carol)
FUND_SATS=10000000 # on-chain funding per node
CH_ALICE_BOB=5000000 # channel capacity alice -> bob
CH_BOB_CAROL=3000000 # channel capacity bob -> carol
CH_CLN_ALICE=4000000 # channel capacity cln -> alice (Core Lightning node)
PUSH_SATS=1000000 # pushed to remote on open, so both sides have liquidity
MINE_CONFIRM=6 # blocks to confirm a funding tx
@ -57,6 +58,11 @@ lncli() {
docker compose exec -T "$node" lncli --network=regtest --lnddir=/home/lnd/.lnd "$@"
}
# Core Lightning cli. Runs inside the cln container against the regtest node.
clncli() {
docker compose exec -T cln lightning-cli --network=regtest "$@"
}
# Extract the first value for a JSON key from lncli output.
# 'first' matters: walletbalance reports confirmed_balance at the top level AND
# again under account_balance.default, and lncli emits no --json flag we can use.
@ -146,8 +152,10 @@ pubkey_of() {
}
log "Connecting peers"
ALICE_PUB=$(pubkey_of alice)
BOB_PUB=$(pubkey_of bob)
CAROL_PUB=$(pubkey_of carol)
info "alice pubkey: $ALICE_PUB"
info "bob pubkey: $BOB_PUB"
info "carol pubkey: $CAROL_PUB"
@ -220,6 +228,44 @@ for amt in 20000 40000; do
info "carol open invoice ${amt} sats"
done
# ---------------------------------------------------------------- core lightning
# A Core Lightning node with one active channel, so RTL's CLN screens have data.
# An active (peer_connected) channel is what exercises the connection-status column.
log "Waiting for Core Lightning node"
wait_for "cln" 120 clncli getinfo
log "Funding Core Lightning (${FUND_SATS} sats)"
CLN_ADDR=$(clncli newaddr | json_first bech32)
[ -n "$CLN_ADDR" ] || die "could not get address for cln"
bcli -rpcwallet=rtldev sendtoaddress "$CLN_ADDR" "$BTC_AMOUNT" >/dev/null
info "cln <- $BTC_AMOUNT BTC ($CLN_ADDR)"
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm cln funding"
log "Waiting for cln confirmed on-chain balance"
for i in $(seq 1 60); do
clncli listfunds | grep -q '"status": "confirmed"' && { info "cln funds confirmed"; break; }
sleep 1
(( i == 60 )) && die "cln never saw confirmed funds"
done
log "Opening channel cln -> alice"
clncli connect "${ALICE_PUB}@alice:9735" >/dev/null 2>&1 || info "cln->alice already connected"
clncli fundchannel "$ALICE_PUB" "$CH_CLN_ALICE" >/dev/null
info "cln -> alice ${CH_CLN_ALICE} sats"
bcli -rpcwallet=rtldev generatetoaddress "$MINE_CONFIRM" "$MINE_ADDR" >/dev/null
info "mined $MINE_CONFIRM blocks to confirm the cln channel"
log "Waiting for the cln channel to become active"
for i in $(seq 1 90); do
if clncli listpeerchannels | grep -o '"state": "[A-Z_]*"' | grep -q "CHANNELD_NORMAL"; then
info "cln channel is CHANNELD_NORMAL"; break
fi
sleep 1
(( i == 90 )) && die "cln channel never reached CHANNELD_NORMAL"
done
# ---------------------------------------------------------------- summary
log "Seed complete"
@ -228,6 +274,8 @@ for n in "${NODES[@]}"; do
bal=$(lncli "$n" walletbalance | json_first confirmed_balance)
printf ' %-6s channels: %-3s on-chain: %s sats\n' "$n" "${chans:-0}" "${bal:-0}"
done
cln_chans=$(clncli listpeerchannels | grep -o '"state": "[A-Z_]*"' | grep -c "CHANNELD_NORMAL" || true)
printf ' %-6s channels: %-3s (Core Lightning)\n' "cln" "${cln_chans:-0}"
# '|| echo 0' would be wrong here: grep -c already prints 0 when it finds nothing
# and then exits 1, so the echo would append a second line.
fwds=$(lncli bob fwdinghistory | grep -c '"chan_id_in"' || true)