Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
*
!Dockerfile
!index.html
!signet.html
!.nojekyll
!assets
!assets/**
129 changes: 129 additions & 0 deletions .github/workflows/webui.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: webui

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.11"
- name: install
run: bun install --frozen-lockfile
- name: verify vendored nostr bundle is up to date
run: |
bun run build:nostr
git diff --exit-code assets/nostr-bundle.js
- name: test
run: bun test test/

pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.11"
- run: bun install --frozen-lockfile
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- run: pip install pre-commit
- run: pre-commit run --all-files

e2e:
runs-on: ubuntu-latest
steps:
# The e2e compose builds the lnproxy image from a sibling relay checkout.
- uses: actions/checkout@v4
with:
path: lnproxy-webui2
# DELETE ME after lnproxy/lnproxy-relay includes its root Dockerfile.
- uses: actions/checkout@v4
with:
repository: m0wer/lnproxy-relay
ref: nostr
path: lnproxy-relay
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.11"
- name: bring up the e2e stack
working-directory: lnproxy-webui2/e2e
run: |
mkdir -p tmp
docker compose -f docker-compose.e2e.yml up -d --build
# Wait for the channel setup to finish and lnproxy to publish.
for i in $(seq 1 60); do
if docker logs lnproxy-e2e-lnproxy 2>&1 | grep -q "published offer"; then
echo "lnproxy is advertising"; break
fi
if [ "$(docker inspect -f '{{.State.Status}}' lnproxy-e2e-lnproxy)" = "exited" ]; then
docker logs lnproxy-e2e-lnproxy
exit 1
fi
sleep 5
done
docker logs lnproxy-e2e-lnproxy 2>&1 | grep -q "published offer"
- name: install playwright
working-directory: lnproxy-webui2/e2e/playwright
run: |
bun install --frozen-lockfile
bunx playwright install --with-deps chromium
- name: run e2e
working-directory: lnproxy-webui2/e2e/playwright
run: bunx playwright test
- name: dump logs on failure
if: failure()
working-directory: lnproxy-webui2/e2e
run: docker compose -f docker-compose.e2e.yml logs --no-color | tail -300
- name: tear down
if: always()
working-directory: lnproxy-webui2/e2e
run: docker compose -f docker-compose.e2e.yml down -v

pages:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test
- pre-commit
- e2e
runs-on: ubuntu-latest
permissions:
contents: read
pages: read
steps:
- uses: actions/checkout@v4
- name: stage public files
run: |
mkdir _site
cp index.html signet.html .nojekyll _site/
cp -R assets _site/assets
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v5
with:
path: _site
include-hidden-files: true

deploy-pages:
needs: pages
runs-on: ubuntu-latest
concurrency:
group: github-pages
cancel-in-progress: false
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: deploy
id: deployment
uses: actions/deploy-pages@v4
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
e2e/tmp/
e2e/playwright/test-results/
e2e/playwright/playwright-report/
e2e/playwright/bun.lock
Empty file added .nojekyll
Empty file.
21 changes: 21 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^assets/(nostr-bundle\.js|qrcode\.js|modern-normalize\.css)$
- id: end-of-file-fixer
exclude: ^assets/(nostr-bundle\.js|qrcode\.js|modern-normalize\.css)$
- id: check-merge-conflict
- id: check-json
exclude: ^bun\.lock$
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
- id: bun-test
name: bun test
entry: bun test test/
language: system
pass_filenames: false
files: ^(assets/.*\.js|test/.*\.js)$
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FROM nginx:1.29-alpine

COPY index.html signet.html .nojekyll /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
132 changes: 123 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,127 @@
# lnproxy-webui2

New webui for lnproxy
Static web UI for decentralized lnproxy provider discovery.

The new ui is just static files with some minimalistic and easily verifiable js code
that interacts with https://github.com/lnproxy/lnproxy through the REST API.
The UI is static files with minimal, auditable JavaScript. It discovers lnproxy
providers through nostr using the protocol in
[the spec](https://github.com/lnproxy/spec/blob/main/nostr.md). After selecting a
provider it prefers a validated direct HTTP or onion endpoint, when offered, and
uses encrypted nostr requests as the fallback transport.

This change of architecture makes it much more secure for users since they
can run it locally and not have to trust the owner of lnproxy.org.
Thus, I took the opportunity to allow the code to request proxy invoices
from arbitrary relays and added some code that verifies that proxy invoices
returned have matching payment hashes and honor the requested
description and routing_msat fields.
## Decentralized provider discovery over nostr

On page load the UI subscribes to configured nostr relays, collects a bounded
set of provider offers, and verifies their signatures, freshness, network tags,
and proof of work. Once an amount-bearing invoice is entered, it filters offers
by amount, feature, minimum proof of work (20 bits by default), and optional
pinned provider. It keeps the cheapest offer for each attested Lightning node,
sorts the remaining identities by provider fee, and selects the cheapest. The
user can select another eligible provider before wrapping. Node attestation
binds keys, but does not prove liquidity, channel capacity, or honest service.
Returned proxy invoices are checked for payment hash, destination, description,
routing budget, and the provider's advertised fee.
Without an explicit routing budget, the reference client permits a fixed 3 sat
routing allowance above the displayed provider fee. An explicit routing budget
is enforced exactly.
Offers requiring more than 24 bits of request proof of work are ignored, and
accepted request work is mined in yielding chunks within the overall request
deadline so the page stays responsive.

The selected network is visible and editable beside the lnproxy name. Mainnet,
signet, testnet, and regtest can be selected directly. Entering an invoice
switches the network automatically from its BOLT11 prefix and refreshes offers
for that network. The repository defaults to mainnet through
`assets/deployment.json`. For a signet deployment, serve an override at the same
path:

```json
{
"network": "signet",
"nostr_relays": ["wss://nos.lol", "wss://relay.primal.net"],
"direct_timeout_ms": 15000
}
```

The direct timeout is one aggregate budget across at most three advertised
endpoints. It defaults to 10 seconds and accepts integer millisecond values from
`1000` through `30000`; Tor-oriented deployments may want a longer value such as
`15000` (15 seconds).
Every direct request names the selected offer pubkey, so an endpoint rejects
work reflected from an offer signed by another provider. Nostr requests use
only the bounded intersection of client-configured and provider-advertised
relays; an offer cannot force the browser to connect to arbitrary relays.

The static `signet.html` entry point redirects to `?network=signet` for servers
that cannot override deployment assets. Explicit `?network=` and
`?nostr_relays=` query parameters remain available for testing; a
`nostrRelays` localStorage CSV can also override the nostr relay set. Network is
not read from localStorage so a stale hidden setting cannot select the wrong
Bitcoin network.

Build the static Nginx image directly from this repository:

docker build -t lnproxy-webui .

The image copies only the public HTML and `assets/` tree; repository metadata,
tests, and local e2e credentials are not included.

Privacy note: a provider sees the invoice it is asked to pay (destination,
amount, and description), and a direct clearnet endpoint also sees the browser's
IP unless a proxy is used. Changing the proxy invoice description cannot hide
the original invoice from the provider. Nostr transport hides the browser IP
from the provider but exposes connection metadata to the nostr relay. A direct
onion endpoint over Tor avoids both kinds of exposure.

## Browser diagnostics

The UI writes structured diagnostics to the browser console with an
`[lnproxy +123ms]`-style prefix. Logs cover deployment configuration, relay
source and connection state, network changes, offer acceptance and filtering,
provider selection, request proof of work, per-relay publication, response
transport, invoice validation, and total timings. Public nostr provider keys,
event IDs, fees, limits, features, relay URLs, and PoW values are included where
useful.

Diagnostics deliberately exclude invoice text, memo/description contents,
payment hashes, ciphertext, decrypted response bodies, and ephemeral nostr
keys. Chrome hides `console.debug` entries unless the **Verbose** level is
enabled; key lifecycle events use `console.info` and remain visible normally.

## GitHub Pages

`.github/workflows/webui.yml` stages only public files and deploys them with the
official GitHub Pages actions on pushes to `main`, after unit, pre-commit, and
end-to-end jobs pass. Set the repository's Pages source to **GitHub Actions**
once under **Settings > Pages**; subsequent successful pushes publish
automatically.

## Static files and the vendored nostr bundle

The site itself needs no build step; serve the files as-is. The only generated
artifact is the vendored, committed nostr-tools bundle
`assets/nostr-bundle.js`. To rebuild it (and run the tests) you need
[Bun](https://bun.sh):

bun install
bun run build:nostr # regenerates assets/nostr-bundle.js
bun test # runs the discovery/validation unit tests

The bundle re-exports only the small nostr-tools surface lnproxy uses (see
`assets/nostr-tools-entry.js`), keeping it auditable.

## End-to-end tests

`e2e/` contains a full browser end-to-end test: a Docker stack with a regtest
Lightning network (bitcoind + two LND nodes with a channel), a nostr relay, and
the lnproxy nostr-relay backend, driven by Playwright. The test issues a real
invoice from one LND node, discovers the provider over nostr in a real browser,
wraps directly through the backend, and checks client-side verification. It also
drops a completed direct response and verifies that the browser recovers through
Nostr with the same request ID, then pays a wrapped invoice through the complete
hold-invoice circuit.

cd e2e
docker compose -f docker-compose.e2e.yml up -d --build
# wait for the `setup` service to finish and lnproxy to publish its offer
cd playwright && bun install && bunx playwright install chromium && bunx playwright test
cd .. && docker compose -f docker-compose.e2e.yml down -v
3 changes: 3 additions & 0 deletions assets/deployment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"network": "mainnet"
}
46 changes: 46 additions & 0 deletions assets/diagnostics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const startedAt = performance.now();

function write(method, event, details) {
try {
const elapsedMs = Math.round(performance.now() - startedAt);
const prefix = `[lnproxy +${elapsedMs}ms] ${event}`;
const logger = console?.[method];
if (typeof logger !== "function") return;
if (details === undefined) {
logger.call(console, prefix);
} else {
logger.call(console, prefix, details);
}
} catch (_error) {
// Diagnostics must never interrupt the user workflow.
}
}

export const diagnostics = {
debug(event, details) {
write("debug", event, details);
},
info(event, details) {
write("info", event, details);
},
warn(event, details) {
write("warn", event, details);
},
error(event, details) {
write("error", event, details);
},
};

export function errorDetails(error) {
return {
name: error?.name || "Error",
message: error?.message || String(error),
stack: error?.stack,
};
}

export function shortID(value) {
return typeof value === "string" && value.length > 16
? `${value.slice(0, 12)}...${value.slice(-4)}`
: value;
}
Loading