feat(pairing): default seed relay to the nostrclient endpoint + validate relays
Some checks failed
ci.yml / feat(pairing): default seed relay to the nostrclient endpoint + validate relays (pull_request) Failing after 0s

Two robustness fixes for on-machine pairing (bitspire-#70), after a QR scan
silently corrupted a seed's relay (ws://→As://) and crash-looped a machine on
an unreachable relay:

- Default relays: when the operator omits `relays`, derive the seed's relay from
  THIS lnbits' own nostrclient proxy endpoint — `<ws(s)>://<host>/nostrclient/api/v1/relay`,
  built from `lnbits_baseurl` (default_relay_endpoint). Operators configure
  upstream relays once in the nostrclient extension (public_ws must be on) and
  every seed points at one stable, operator-independent URL. `relays` is now
  optional on PairMachineData + the /pair endpoint.
- Validate every relay (+ bunker_relay) is a `ws://`/`wss://` URL AND reject
  loopback hosts (localhost/127.0.0.1/::1/0.0.0.0) — a seed is redeemed by a
  REMOTE machine, so a localhost relay is exactly the unreachable case. Catches
  both the ws://→As:// corruption and the localhost /pair gotcha at mint time.

Consumer side (bitspire): parseSpireSeed rejects non-ws relays, and the wizard
gained a "test relay" reachability button before committing.

223 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-02 00:00:13 +02:00
commit c3791ed6c8
5 changed files with 143 additions and 27 deletions

View file

@ -44,7 +44,8 @@ from __future__ import annotations
import base64
import json
from urllib.parse import quote
import re
from urllib.parse import quote, urlparse
from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient,
@ -172,10 +173,49 @@ def build_seed_url(
return SEED_URL_SCHEME + blob
_WS_SCHEME_RE = re.compile(r"^wss?://", re.IGNORECASE)
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
# The nostrclient extension serves a public relay-multiplexer websocket at this
# path (see nostrclient/views_api.py `ws_relay`, gated by config.public_ws).
NOSTRCLIENT_RELAY_PATH = "/nostrclient/api/v1/relay"
def default_relay_endpoint() -> str | None:
"""Derive the machine-facing relay from THIS lnbits' own nostrclient proxy
endpoint, so operators configure upstream relays once in the nostrclient
extension and every seed points at one stable URL (bitspire#70). Built from
`settings.lnbits_baseurl` (httpws, httpswss). Returns None when the base
URL is unset. Requires the nostrclient extension's `public_ws` to be on."""
base = (settings.lnbits_baseurl or "").strip().rstrip("/")
if not base:
return None
if base.startswith("https://"):
base = "wss://" + base[len("https://") :]
elif base.startswith("http://"):
base = "ws://" + base[len("http://") :]
return base + NOSTRCLIENT_RELAY_PATH
def _validate_relay(url: str, field: str) -> None:
"""A seed relay must be a `ws(s)://` URL reachable by a REMOTE machine. Reject
loopback hosts: a localhost relay in a seed is the exact unreachable case
that silently crash-loops pairing (bitspire#70)."""
if not isinstance(url, str) or not _WS_SCHEME_RE.match(url):
raise PairingError(f"{field} must be a ws:// or wss:// URL (got {url!r})")
host = (urlparse(url).hostname or "").lower()
if host in _LOCAL_HOSTS or host.endswith(".localhost"):
raise PairingError(
f"{field} points at localhost ({url!r}). A pairing seed is redeemed by "
"a REMOTE machine — set lnbits_baseurl (and any explicit relay) to a "
"machine-reachable address."
)
async def pair_spire(
machine: Machine,
*,
relays: list[str],
relays: list[str] | None = None,
admin_client: NsecBunkerAdminClient,
bunker_relay: str | None = None,
keystore_passphrase: str | None = None,
@ -199,10 +239,14 @@ async def pair_spire(
with a fake client.
`relays` are the relays the spire uses for its *own* events
(kind-21000/30078) typically the operator's public nostrrelay; supplied by
the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the
spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase`
defaults to the lnbits bunker setting. Both injectable for tests.
(kind-21000/30078). When omitted, they default to this lnbits' nostrclient
proxy endpoint (`default_relay_endpoint`, derived from `lnbits_baseurl`) so
operators configure upstream relays once in the extension and every seed
points at one stable URL. Every relay (and `bunker_relay`) is validated as a
remote-reachable `ws(s)://` URL a loopback host is rejected (bitspire#70).
`bunker_relay` (the relay baked into `bunker_url`, where the spire reaches
the bunker) defaults to `relays[0]`; `keystore_passphrase` defaults to the
lnbits bunker setting. All injectable for tests.
Raises PairingError on any bunker failure; no state is persisted here
(the API layer persists on success).
@ -217,15 +261,27 @@ async def pair_spire(
"LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — "
"cannot mint a spire key"
)
# Default the spire's event relay(s) to this lnbits' nostrclient proxy
# endpoint when the caller gives none.
if not relays:
raise PairingError("at least one relay is required for the seed URL")
# The relay baked into `bunker_url` is where the *spire* (the remote ATM)
# reaches the bunker, so it must be a machine-reachable public URL — NOT
# `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits
# reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM —
# the localhost-relay /pair gotcha bitspire flagged). Default to the spire's
# own event relay (the bunker lives on the same operator relay the spire
# publishes to); an explicit `bunker_relay` overrides for split-relay deploys.
endpoint = default_relay_endpoint()
if not endpoint:
raise PairingError(
"no relays given and lnbits_baseurl is unset — cannot derive the "
"nostrclient relay endpoint for the seed"
)
relays = [endpoint]
# Validate every relay is a remote-reachable ws(s):// URL (rejects loopback +
# the ws://→As:// class of corruption). The bunker relay is validated too:
# it's baked into `bunker_url` where the *spire* (remote ATM) reaches the
# bunker, so it must be machine-reachable — NOT `settings.lnbits_nsec_bunker_url`
# (the co-located ws://127.0.0.1 path). Default it to the spire's own event
# relay; an explicit `bunker_relay` overrides for split-relay deploys.
for i, r in enumerate(relays):
_validate_relay(r, f"relays[{i}]")
if bunker_relay:
_validate_relay(bunker_relay, "bunker_relay")
relay = bunker_relay if bunker_relay else relays[0]
key_name = spire_key_name(machine.id)