fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy
Some checks failed
ci.yml / fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy (pull_request) Failing after 0s

The nostrclient endpoint is a subscription MULTIPLEXER, not a full relay: its
router forwards a client's EVENT upstream but never returns an OK ack (see
nostrclient/router.py). A transport client that awaits OK on publish therefore
times out ("publish timed out"), so kind-21000 RPCs never complete — verified
on the Sintra: connect succeeded but list_wallets hung, and switching to the
nostrrelay endpoint made the whole flow work (wallet, balance, availability).

default_relay_endpoint now derives from settings.nostr_transport_relays — the
relay the transport actually listens on: use it as-is when already
machine-reachable, or re-home its path on lnbits_baseurl when it's a co-located
loopback relay (the bundled nostrrelay). Validation/localhost-reject and the
pair-dialog pre-fill/hint carry over; wording updated to "transport relay".

227 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-02 00:43:54 +02:00
commit 0bb9939822
6 changed files with 89 additions and 49 deletions

View file

@ -94,8 +94,8 @@ 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.
`relays` is optional: when omitted it defaults to this lnbits' nostrclient `relays` is optional: when omitted it defaults to the relay the transport
proxy endpoint (derived from `lnbits_baseurl`), so the operator needn't listens on (derived from the transport config), so the operator needn't
supply one (bitspire#70). `duration_hours` optionally time-bounds the supply one (bitspire#70). `duration_hours` optionally time-bounds the
spire's connect token (None = non-expiring).""" spire's connect token (None = non-expiring)."""

View file

@ -199,25 +199,41 @@ def build_seed_url(
_WS_SCHEME_RE = re.compile(r"^wss?://", re.IGNORECASE) _WS_SCHEME_RE = re.compile(r"^wss?://", re.IGNORECASE)
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"} _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). def _baseurl_ws() -> str | None:
NOSTRCLIENT_RELAY_PATH = "/nostrclient/api/v1/relay" """`settings.lnbits_baseurl` as a ws(s):// scheme+host (no trailing slash)."""
base = (settings.lnbits_baseurl or "").strip().rstrip("/")
if base.startswith("https://"):
return "wss://" + base[len("https://") :]
if base.startswith("http://"):
return "ws://" + base[len("http://") :]
return None
def default_relay_endpoint() -> str | None: def default_relay_endpoint() -> str | None:
"""Derive the machine-facing relay from THIS lnbits' own nostrclient proxy """Derive the machine-facing relay from lnbits' nostr-transport config, so a
endpoint, so operators configure upstream relays once in the nostrclient seed points at the relay the transport ACTUALLY listens on (bitspire#70).
extension and every seed points at one stable URL (bitspire#70). Built from
`settings.lnbits_baseurl` (httpws, httpswss). Returns None when the base NOTE: this is the nostr *relay* (a full relay that acks EVENTs), NOT the
URL is unset. Requires the nostrclient extension's `public_ws` to be on.""" nostrclient proxy the client multiplexer forwards subscriptions but never
base = (settings.lnbits_baseurl or "").strip().rstrip("/") sends `OK`, so a transport client that awaits `OK` on publish times out.
If the transport relay is already machine-reachable, use it as-is; if it's a
co-located loopback relay (the bundled nostrrelay at
`ws://localhost:5001/nostrrelay/<id>`), re-home its path on lnbits' public
base URL so a remote ATM can reach the same relay. Returns None if neither
a transport relay nor a base URL is available."""
relays = settings.nostr_transport_relays or []
if not relays:
return None
relay = relays[0]
host = (urlparse(relay).hostname or "").lower()
if host and host not in _LOCAL_HOSTS and not host.endswith(".localhost"):
return relay # already remote-reachable
base = _baseurl_ws()
if not base: if not base:
return None return None
if base.startswith("https://"): return base + urlparse(relay).path
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: def _validate_relay(url: str, field: str) -> None:
@ -262,11 +278,11 @@ 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). When omitted, they default to this lnbits' nostrclient (kind-21000/30078). When omitted, they default to the relay the transport
proxy endpoint (`default_relay_endpoint`, derived from `lnbits_baseurl`) so listens on (`default_relay_endpoint`, from `nostr_transport_relays` re-homed
operators configure upstream relays once in the extension and every seed on `lnbits_baseurl`) so a seed points at a relay that actually acks EVENTs.
points at one stable URL. Every relay (and `bunker_relay`) is validated as a Every relay (and `bunker_relay`) is validated as a remote-reachable
remote-reachable `ws(s)://` URL a loopback host is rejected (bitspire#70). `ws(s)://` URL a loopback host is rejected (bitspire#70).
`bunker_relay` (the relay baked into `bunker_url`, where the spire reaches `bunker_relay` (the relay baked into `bunker_url`, where the spire reaches
the bunker) defaults to `relays[0]`; `keystore_passphrase` defaults to the the bunker) defaults to `relays[0]`; `keystore_passphrase` defaults to the
lnbits bunker setting. All injectable for tests. lnbits bunker setting. All injectable for tests.
@ -284,14 +300,14 @@ 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 # Default the spire's event relay(s) to the transport's own relay (re-homed
# endpoint when the caller gives none. # on the base URL) when the caller gives none.
if not relays: if not relays:
endpoint = default_relay_endpoint() endpoint = default_relay_endpoint()
if not endpoint: if not endpoint:
raise PairingError( raise PairingError(
"no relays given and lnbits_baseurl is unset — cannot derive the " "no relays given and none could be derived — set "
"nostrclient relay endpoint for the seed" "nostr_transport_relays (and lnbits_baseurl if it's a loopback relay)"
) )
relays = [endpoint] relays = [endpoint]

View file

@ -868,7 +868,7 @@
<q-input <q-input
v-model="pairDialog.relays" v-model="pairDialog.relays"
label="Relay(s) for the spire's events" label="Relay(s) for the spire's events"
hint="Pre-filled with this instance's nostrclient relay endpoint (derived from your Base URL). Leave as-is unless the spire must use a different relay. One per line; blank uses the default." hint="Pre-filled with the relay this instance's nostr transport listens on. Leave as-is unless the spire must use a different relay. One per line; blank uses the default."
type="textarea" autogrow type="textarea" autogrow
class="q-mb-md" class="q-mb-md"
dense outlined></q-input> dense outlined></q-input>

View file

@ -177,13 +177,16 @@ def test_revoke_failure_maps_to_bad_gateway(monkeypatch):
assert state["unpaired"] is None # not persisted on failure assert state["unpaired"] is None # not persisted on failure
def test_default_relay_endpoint_returns_nostrclient_url(): def test_default_relay_endpoint_returns_transport_relay():
from lnbits.settings import settings from lnbits.settings import settings
prev = settings.lnbits_baseurl prev_base = settings.lnbits_baseurl
prev_relays = settings.nostr_transport_relays
try: try:
settings.lnbits_baseurl = "https://lnbits.example.com/" settings.lnbits_baseurl = "https://lnbits.example.com/"
settings.nostr_transport_relays = ["ws://localhost:5001/nostrrelay/test"]
result = asyncio.run(views_api.api_default_relay(SimpleNamespace(id="op1"))) result = asyncio.run(views_api.api_default_relay(SimpleNamespace(id="op1")))
assert result == {"relay": "wss://lnbits.example.com/nostrclient/api/v1/relay"} assert result == {"relay": "wss://lnbits.example.com/nostrrelay/test"}
finally: finally:
settings.lnbits_baseurl = prev settings.lnbits_baseurl = prev_base
settings.nostr_transport_relays = prev_relays

View file

@ -35,7 +35,9 @@ from ..pairing import (
) )
_BASEURL = "https://lnbits.example.com/" _BASEURL = "https://lnbits.example.com/"
_NOSTRCLIENT_ENDPOINT = "wss://lnbits.example.com/nostrclient/api/v1/relay" _TRANSPORT_RELAY = "ws://localhost:5001/nostrrelay/test"
# baseurl host (wss://lnbits.example.com) + transport relay path (/nostrrelay/test)
_DEFAULT_RELAY = "wss://lnbits.example.com/nostrrelay/test"
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) _NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" _SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
@ -53,11 +55,14 @@ def _set_transport_pubkey():
# 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 prev_base = settings.lnbits_baseurl
prev_relays = settings.nostr_transport_relays
settings.nostr_transport_public_key = _LNBITS_HEX settings.nostr_transport_public_key = _LNBITS_HEX
settings.lnbits_baseurl = _BASEURL settings.lnbits_baseurl = _BASEURL
settings.nostr_transport_relays = [_TRANSPORT_RELAY]
yield yield
settings.nostr_transport_public_key = prev settings.nostr_transport_public_key = prev
settings.lnbits_baseurl = prev_base settings.lnbits_baseurl = prev_base
settings.nostr_transport_relays = prev_relays
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -327,8 +332,8 @@ def test_missing_passphrase_raises():
) )
def test_relays_default_to_nostrclient_endpoint(): def test_relays_default_to_transport_relay():
# No relays given → derived from lnbits_baseurl → nostrclient proxy endpoint. # No relays given → derived from the transport relay, re-homed on baseurl.
result = asyncio.run( result = asyncio.run(
pair_spire( pair_spire(
_machine(), _machine(),
@ -336,34 +341,51 @@ def test_relays_default_to_nostrclient_endpoint():
keystore_passphrase=_PASSPHRASE, keystore_passphrase=_PASSPHRASE,
) )
) )
assert _decode_seed(result.seed_url)["relays"] == [_NOSTRCLIENT_ENDPOINT] assert _decode_seed(result.seed_url)["relays"] == [_DEFAULT_RELAY]
def test_default_relay_endpoint_scheme_map(): def test_default_relay_endpoint_rehomes_loopback_transport_relay():
prev = settings.lnbits_baseurl prev_base = settings.lnbits_baseurl
prev_relays = settings.nostr_transport_relays
try: try:
# loopback transport relay → re-homed on the base URL host
settings.nostr_transport_relays = ["ws://localhost:5001/nostrrelay/test"]
settings.lnbits_baseurl = "http://192.168.0.32:5001" settings.lnbits_baseurl = "http://192.168.0.32:5001"
assert default_relay_endpoint() == "ws://192.168.0.32:5001/nostrclient/api/v1/relay" assert default_relay_endpoint() == "ws://192.168.0.32:5001/nostrrelay/test"
settings.lnbits_baseurl = "https://lnbits.example.com/" settings.lnbits_baseurl = "https://lnbits.example.com/"
assert default_relay_endpoint() == _NOSTRCLIENT_ENDPOINT assert default_relay_endpoint() == _DEFAULT_RELAY
# loopback relay but no base URL → can't re-home → None
settings.lnbits_baseurl = "" settings.lnbits_baseurl = ""
assert default_relay_endpoint() is None assert default_relay_endpoint() is None
finally: finally:
settings.lnbits_baseurl = prev settings.lnbits_baseurl = prev_base
settings.nostr_transport_relays = prev_relays
def test_no_relays_and_no_baseurl_raises(): def test_default_relay_endpoint_passes_through_reachable_transport_relay():
prev = settings.lnbits_baseurl prev = settings.nostr_transport_relays
try: try:
settings.lnbits_baseurl = "" # already remote-reachable → used as-is, base URL irrelevant
with pytest.raises(PairingError, match="cannot derive"): settings.nostr_transport_relays = ["wss://relay.aiolabs.dev"]
assert default_relay_endpoint() == "wss://relay.aiolabs.dev"
settings.nostr_transport_relays = []
assert default_relay_endpoint() is None
finally:
settings.nostr_transport_relays = prev
def test_no_relays_and_no_transport_relay_raises():
prev = settings.nostr_transport_relays
try:
settings.nostr_transport_relays = []
with pytest.raises(PairingError, match="none could be derived"):
asyncio.run( asyncio.run(
pair_spire( pair_spire(
_machine(), admin_client=FakeBunker(), keystore_passphrase=_PASSPHRASE _machine(), admin_client=FakeBunker(), keystore_passphrase=_PASSPHRASE
) )
) )
finally: finally:
settings.lnbits_baseurl = prev settings.nostr_transport_relays = prev
@pytest.mark.parametrize( @pytest.mark.parametrize(

View file

@ -301,9 +301,8 @@ async def api_create_machine(
@spirekeeper_api_router.get("/api/v1/dca/default-relay") @spirekeeper_api_router.get("/api/v1/dca/default-relay")
async def api_default_relay(user: User = Depends(check_user_exists)) -> dict: async def api_default_relay(user: User = Depends(check_user_exists)) -> dict:
"""The relay a pairing seed defaults to when the operator leaves it blank — """The relay a pairing seed defaults to when the operator leaves it blank —
this lnbits' nostrclient proxy endpoint, derived from lnbits_baseurl the relay the transport listens on, derived from the transport config
(bitspire#70). The pair dialog pre-fills it. `None` if lnbits_baseurl is (bitspire#70). The pair dialog pre-fills it. `None` if it can't be derived."""
unset."""
_ = user _ = user
return {"relay": default_relay_endpoint()} return {"relay": default_relay_endpoint()}
@ -326,8 +325,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)
# relays may be omitted — pair_spire defaults to this lnbits' nostrclient # relays may be omitted — pair_spire defaults to the transport relay and
# proxy endpoint and validates reachability (raises PairingError → 502). # validates reachability (raises PairingError → 502).
try: try:
async with NsecBunkerAdminClient.from_settings() as client: async with NsecBunkerAdminClient.from_settings() as client: