feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) #37
5 changed files with 143 additions and 27 deletions
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
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>
commit
c3791ed6c8
|
|
@ -94,10 +94,12 @@ class PairMachineData(BaseModel):
|
|||
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
|
||||
LAN/public URL (`ws://192.168.0.32:5001/…`), or any split-relay deploy.
|
||||
`duration_hours` optionally time-bounds the spire's connect token
|
||||
(None = non-expiring)."""
|
||||
`relays` is optional: when omitted it defaults to this lnbits' nostrclient
|
||||
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
|
||||
duration_hours: int | None = None
|
||||
|
||||
|
|
|
|||
84
pairing.py
84
pairing.py
|
|
@ -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` (http→ws, https→wss). 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)
|
||||
|
|
|
|||
|
|
@ -113,11 +113,12 @@ def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
|
|||
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)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
_call([])
|
||||
assert ei.value.status_code == 400
|
||||
result = _call([])
|
||||
assert result.seed_url == "spire-seed:v1:abc"
|
||||
|
||||
|
||||
def test_pair_failure_maps_to_bad_gateway(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ from ..pairing import (
|
|||
SPIRE_POLICY_RULES,
|
||||
PairingError,
|
||||
build_seed_url,
|
||||
default_relay_endpoint,
|
||||
pair_spire,
|
||||
revoke_spire,
|
||||
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)
|
||||
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
|
||||
_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
|
||||
# lnbits_npub in the seed (bitspire#70). Set it for every test; restore after.
|
||||
prev = settings.nostr_transport_public_key
|
||||
prev_base = settings.lnbits_baseurl
|
||||
settings.nostr_transport_public_key = _LNBITS_HEX
|
||||
settings.lnbits_baseurl = _BASEURL
|
||||
yield
|
||||
settings.nostr_transport_public_key = prev
|
||||
settings.lnbits_baseurl = prev_base
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def test_missing_relay_or_passphrase_raises():
|
||||
def test_missing_passphrase_raises():
|
||||
with pytest.raises(PairingError, match="PASSPHRASE"):
|
||||
asyncio.run(
|
||||
pair_spire(
|
||||
|
|
@ -274,13 +281,63 @@ def test_missing_relay_or_passphrase_raises():
|
|||
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(
|
||||
pair_spire(
|
||||
_machine(),
|
||||
relays=[],
|
||||
relays=[bad_relay],
|
||||
admin_client=FakeBunker(),
|
||||
bunker_relay=_BUNKER_RELAY,
|
||||
keystore_passphrase=_PASSPHRASE,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -315,8 +315,8 @@ async def api_pair_machine(
|
|||
`duration_hours` (optional) time-bounds the token; revoke via the
|
||||
sibling `POST .../revoke` endpoint."""
|
||||
machine = await _machine_owned_by(machine_id, user.id)
|
||||
if not data.relays:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required")
|
||||
# relays may be omitted — pair_spire defaults to this lnbits' nostrclient
|
||||
# proxy endpoint and validates reachability (raises PairingError → 502).
|
||||
|
||||
try:
|
||||
async with NsecBunkerAdminClient.from_settings() as client:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue