feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) #37

Merged
padreug merged 7 commits from bitspire-70-seed-lnbits-npub into main 2026-07-02 17:45:02 +00:00
5 changed files with 143 additions and 27 deletions
Showing only changes of commit c3791ed6c8 - Show all commits

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>
Padreug 2026-07-02 00:00:13 +02:00

View file

@ -94,10 +94,12 @@ class PairMachineData(BaseModel):
the relay lnbits uses to reach the bunker differs from the one the spire the relay lnbits uses to reach the bunker differs from the one the spire
must reach e.g. an internal docker hostname (`ws://lnbits:5001/`) vs a must reach e.g. an internal docker hostname (`ws://lnbits:5001/`) vs a
LAN/public URL (`ws://192.168.0.32:5001/`), or any split-relay deploy. LAN/public URL (`ws://192.168.0.32:5001/`), or any split-relay deploy.
`duration_hours` optionally time-bounds the spire's connect token `relays` is optional: when omitted it defaults to this lnbits' nostrclient
(None = non-expiring).""" proxy endpoint (derived from `lnbits_baseurl`), so the operator needn't
supply one (bitspire#70). `duration_hours` optionally time-bounds the
spire's connect token (None = non-expiring)."""
relays: list[str] relays: list[str] | None = None
bunker_relay: str | None = None bunker_relay: str | None = None
duration_hours: int | None = None duration_hours: int | None = None

View file

@ -44,7 +44,8 @@ from __future__ import annotations
import base64 import base64
import json import json
from urllib.parse import quote import re
from urllib.parse import quote, urlparse
from lnbits.core.services.nsec_bunker import ( from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient, NsecBunkerAdminClient,
@ -172,10 +173,49 @@ def build_seed_url(
return SEED_URL_SCHEME + blob 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( async def pair_spire(
machine: Machine, machine: Machine,
*, *,
relays: list[str], relays: list[str] | None = None,
admin_client: NsecBunkerAdminClient, admin_client: NsecBunkerAdminClient,
bunker_relay: str | None = None, bunker_relay: str | None = None,
keystore_passphrase: str | None = None, keystore_passphrase: str | None = None,
@ -199,10 +239,14 @@ async def pair_spire(
with a fake client. with a fake client.
`relays` are the relays the spire uses for its *own* events `relays` are the relays the spire uses for its *own* events
(kind-21000/30078) typically the operator's public nostrrelay; supplied by (kind-21000/30078). When omitted, they default to this lnbits' nostrclient
the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the proxy endpoint (`default_relay_endpoint`, derived from `lnbits_baseurl`) so
spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase` operators configure upstream relays once in the extension and every seed
defaults to the lnbits bunker setting. Both injectable for tests. 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 Raises PairingError on any bunker failure; no state is persisted here
(the API layer persists on success). (the API layer persists on success).
@ -217,15 +261,27 @@ async def pair_spire(
"LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — "
"cannot mint a spire key" "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: if not relays:
raise PairingError("at least one relay is required for the seed URL") endpoint = default_relay_endpoint()
# The relay baked into `bunker_url` is where the *spire* (the remote ATM) if not endpoint:
# reaches the bunker, so it must be a machine-reachable public URL — NOT raise PairingError(
# `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits "no relays given and lnbits_baseurl is unset — cannot derive the "
# reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM — "nostrclient relay endpoint for the seed"
# 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 relays = [endpoint]
# publishes to); an explicit `bunker_relay` overrides for split-relay deploys.
# 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] relay = bunker_relay if bunker_relay else relays[0]
key_name = spire_key_name(machine.id) key_name = spire_key_name(machine.id)

View file

@ -113,11 +113,12 @@ def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1") assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1")
def test_pair_empty_relays_rejected(monkeypatch): def test_pair_empty_relays_accepted(monkeypatch):
# Empty/omitted relays are no longer a 400 — pair_spire defaults them to the
# nostrclient endpoint (and validates), so the endpoint passes them through.
_wire(monkeypatch) _wire(monkeypatch)
with pytest.raises(HTTPException) as ei: result = _call([])
_call([]) assert result.seed_url == "spire-seed:v1:abc"
assert ei.value.status_code == 400
def test_pair_failure_maps_to_bad_gateway(monkeypatch): def test_pair_failure_maps_to_bad_gateway(monkeypatch):

View file

@ -28,11 +28,15 @@ from ..pairing import (
SPIRE_POLICY_RULES, SPIRE_POLICY_RULES,
PairingError, PairingError,
build_seed_url, build_seed_url,
default_relay_endpoint,
pair_spire, pair_spire,
revoke_spire, revoke_spire,
spire_key_name, spire_key_name,
) )
_BASEURL = "https://lnbits.example.com/"
_NOSTRCLIENT_ENDPOINT = "wss://lnbits.example.com/nostrclient/api/v1/relay"
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) _NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" _SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) _SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
@ -48,9 +52,12 @@ def _set_transport_pubkey():
# pair_spire reads this lnbits' nostr-transport server pubkey to embed # pair_spire reads this lnbits' nostr-transport server pubkey to embed
# lnbits_npub in the seed (bitspire#70). Set it for every test; restore after. # lnbits_npub in the seed (bitspire#70). Set it for every test; restore after.
prev = settings.nostr_transport_public_key prev = settings.nostr_transport_public_key
prev_base = settings.lnbits_baseurl
settings.nostr_transport_public_key = _LNBITS_HEX settings.nostr_transport_public_key = _LNBITS_HEX
settings.lnbits_baseurl = _BASEURL
yield yield
settings.nostr_transport_public_key = prev settings.nostr_transport_public_key = prev
settings.lnbits_baseurl = prev_base
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -263,7 +270,7 @@ def test_bunker_relay_defaults_to_spire_event_relay():
assert "127.0.0.1" not in result.bunker_url assert "127.0.0.1" not in result.bunker_url
def test_missing_relay_or_passphrase_raises(): def test_missing_passphrase_raises():
with pytest.raises(PairingError, match="PASSPHRASE"): with pytest.raises(PairingError, match="PASSPHRASE"):
asyncio.run( asyncio.run(
pair_spire( pair_spire(
@ -274,13 +281,63 @@ def test_missing_relay_or_passphrase_raises():
keystore_passphrase="", keystore_passphrase="",
) )
) )
with pytest.raises(PairingError, match="relay is required"):
def test_relays_default_to_nostrclient_endpoint():
# No relays given → derived from lnbits_baseurl → nostrclient proxy endpoint.
result = asyncio.run(
pair_spire(
_machine(),
admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret
keystore_passphrase=_PASSPHRASE,
)
)
assert _decode_seed(result.seed_url)["relays"] == [_NOSTRCLIENT_ENDPOINT]
def test_default_relay_endpoint_scheme_map():
prev = settings.lnbits_baseurl
try:
settings.lnbits_baseurl = "http://192.168.0.32:5001"
assert default_relay_endpoint() == "ws://192.168.0.32:5001/nostrclient/api/v1/relay"
settings.lnbits_baseurl = "https://lnbits.example.com/"
assert default_relay_endpoint() == _NOSTRCLIENT_ENDPOINT
settings.lnbits_baseurl = ""
assert default_relay_endpoint() is None
finally:
settings.lnbits_baseurl = prev
def test_no_relays_and_no_baseurl_raises():
prev = settings.lnbits_baseurl
try:
settings.lnbits_baseurl = ""
with pytest.raises(PairingError, match="cannot derive"):
asyncio.run(
pair_spire(
_machine(), admin_client=FakeBunker(), keystore_passphrase=_PASSPHRASE
)
)
finally:
settings.lnbits_baseurl = prev
@pytest.mark.parametrize(
"bad_relay, match",
[
("As://192.168.0.32:5001/x", "ws://"), # QR misread ws://→As://
("http://192.168.0.32:5001/x", "ws://"),
("ws://localhost:5001/nostrrelay/test", "localhost"),
("ws://127.0.0.1:5001/nostrrelay/test", "localhost"),
],
)
def test_rejects_bad_relay(bad_relay, match):
with pytest.raises(PairingError, match=match):
asyncio.run( asyncio.run(
pair_spire( pair_spire(
_machine(), _machine(),
relays=[], relays=[bad_relay],
admin_client=FakeBunker(), admin_client=FakeBunker(),
bunker_relay=_BUNKER_RELAY,
keystore_passphrase=_PASSPHRASE, keystore_passphrase=_PASSPHRASE,
) )
) )

View file

@ -315,8 +315,8 @@ async def api_pair_machine(
`duration_hours` (optional) time-bounds the token; revoke via the `duration_hours` (optional) time-bounds the token; revoke via the
sibling `POST .../revoke` endpoint.""" sibling `POST .../revoke` endpoint."""
machine = await _machine_owned_by(machine_id, user.id) machine = await _machine_owned_by(machine_id, user.id)
if not data.relays: # relays may be omitted — pair_spire defaults to this lnbits' nostrclient
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required") # proxy endpoint and validates reachability (raises PairingError → 502).
try: try:
async with NsecBunkerAdminClient.from_settings() as client: async with NsecBunkerAdminClient.from_settings() as client: