fix(pairing): default bunker_relay to the spire's public event relay, not localhost #35

Merged
padreug merged 1 commit from fix/pair-bunker-relay-default into main 2026-06-22 15:21:13 +00:00
2 changed files with 32 additions and 22 deletions

View file

@ -179,26 +179,20 @@ async def pair_spire(
connection lifecycle out of the orchestration so this is unit-testable connection lifecycle out of the orchestration so this is unit-testable
with a fake client. with a fake client.
`bunker_relay` / `keystore_passphrase` default to the lnbits bunker `relays` are the relays the spire uses for its *own* events
settings; injectable for tests. `relays` are the relays the spire will (kind-21000/30078) typically the operator's public nostrrelay; supplied by
use for its *own* events (kind-21000/30078) typically the operator's the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the
nostrrelay; supplied by the API layer. spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase`
defaults to the lnbits bunker setting. Both 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).
""" """
relay = (
bunker_relay if bunker_relay is not None else settings.lnbits_nsec_bunker_url
)
passphrase = ( passphrase = (
keystore_passphrase keystore_passphrase
if keystore_passphrase is not None if keystore_passphrase is not None
else settings.lnbits_nsec_bunker_keystore_passphrase else settings.lnbits_nsec_bunker_keystore_passphrase
) )
if not relay:
raise PairingError(
"LNBITS_NSEC_BUNKER_URL is not set — cannot build a spire bunker connection"
)
if not passphrase: if not passphrase:
raise PairingError( raise PairingError(
"LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — "
@ -206,6 +200,14 @@ async def pair_spire(
) )
if not relays: if not relays:
raise PairingError("at least one relay is required for the seed URL") 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.
relay = bunker_relay if bunker_relay else relays[0]
key_name = spire_key_name(machine.id) key_name = spire_key_name(machine.id)
client_name = f"spire-client-{machine.id}" client_name = f"spire-client-{machine.id}"
@ -250,9 +252,7 @@ async def pair_spire(
) )
async def revoke_spire( async def revoke_spire(machine: Machine, *, admin_client: NsecBunkerAdminClient) -> int:
machine: Machine, *, admin_client: NsecBunkerAdminClient
) -> int:
"""Revoke a spire's bunker access (the "Revoke spire access" UX, """Revoke a spire's bunker access (the "Revoke spire access" UX,
aiolabs/spirekeeper#9/#12). aiolabs/spirekeeper#9/#12).
@ -274,6 +274,4 @@ async def revoke_spire(
except NsecBunkerNotConfiguredError as exc: except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
except NsecBunkerError as exc: except NsecBunkerError as exc:
raise PairingError( raise PairingError(f"bunker admin RPC failed during revoke: {exc}") from exc
f"bunker admin RPC failed during revoke: {exc}"
) from exc

View file

@ -218,17 +218,29 @@ def test_malformed_token_raises():
_pair(bunker) _pair(bunker)
def test_missing_relay_or_passphrase_raises(): def test_bunker_relay_defaults_to_spire_event_relay():
with pytest.raises(PairingError, match="LNBITS_NSEC_BUNKER_URL"): """No explicit bunker_relay -> the relay baked into bunker_url is the spire's
asyncio.run( own public event relay (relays[0]), NOT lnbits's internal bunker URL. This
is the localhost-relay /pair gotcha: a UI-minted seed (the form has no
bunker_relay field) must embed a machine-reachable relay, not ws://127.0.0.1.
An empty bunker_relay falls back to the same default."""
from urllib.parse import quote
for empty in (None, ""):
result = asyncio.run(
pair_spire( pair_spire(
_machine(), _machine(),
relays=_RELAYS, relays=_RELAYS,
admin_client=FakeBunker(), admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret
bunker_relay="", bunker_relay=empty,
keystore_passphrase=_PASSPHRASE, keystore_passphrase=_PASSPHRASE,
) )
) )
assert f"relay={quote(_RELAYS[0], safe='')}" in result.bunker_url
assert "127.0.0.1" not in result.bunker_url
def test_missing_relay_or_passphrase_raises():
with pytest.raises(PairingError, match="PASSPHRASE"): with pytest.raises(PairingError, match="PASSPHRASE"):
asyncio.run( asyncio.run(
pair_spire( pair_spire(