From b55fc8bc1cba852f488f8707cca98674dc533064 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 17:18:24 +0200 Subject: [PATCH] fix(pairing): default bunker_relay to the spire's public event relay, not localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed minted via the Pair UI baked an unreachable bunker relay into bunker_url. The UI form has no bunker_relay field, so pair_spire fell back to its default `settings.lnbits_nsec_bunker_url` — which on a deployed instance is the INTERNAL relay lnbits uses to reach the co-located bunker (e.g. ws://127.0.0.1:5000/nostrrelay/demo). The remote ATM can't reach localhost, so connectNewSeed hangs -> BunkerTimeoutError "Signer Unreachable". (Flagged by bitspire on the demo; the localhost-relay /pair gotcha the coord thread called out.) Default bunker_relay to the spire's own public event relay (relays[0]) instead: the bunker lives on the same operator nostrrelay the spire publishes its events to, so that URL is machine-reachable. An explicit `bunker_relay` still overrides for split-relay deploys. An empty override now falls back to the same default rather than raising. Regression test: with no (or empty) bunker_relay, bunker_url embeds relays[0] and contains no 127.0.0.1. NOTE: relays[0] is a pragmatic default; whether the seed should carry multiple relays / be sourced from the operator's nostrclient relay is a follow-up. --- pairing.py | 32 +++++++++++++++----------------- tests/test_pairing.py | 22 +++++++++++++++++----- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/pairing.py b/pairing.py index be7a6a4..c2d923f 100644 --- a/pairing.py +++ b/pairing.py @@ -179,26 +179,20 @@ async def pair_spire( connection lifecycle out of the orchestration so this is unit-testable with a fake client. - `bunker_relay` / `keystore_passphrase` default to the lnbits bunker - settings; injectable for tests. `relays` are the relays the spire will - use for its *own* events (kind-21000/30078) — typically the operator's - nostrrelay; supplied by the API layer. + `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. Raises PairingError on any bunker failure; no state is persisted here (the API layer persists on success). """ - relay = ( - bunker_relay if bunker_relay is not None else settings.lnbits_nsec_bunker_url - ) passphrase = ( keystore_passphrase if keystore_passphrase is not None 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: raise PairingError( "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " @@ -206,6 +200,14 @@ async def pair_spire( ) 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. + relay = bunker_relay if bunker_relay else relays[0] key_name = spire_key_name(machine.id) client_name = f"spire-client-{machine.id}" @@ -250,9 +252,7 @@ async def pair_spire( ) -async def revoke_spire( - machine: Machine, *, admin_client: NsecBunkerAdminClient -) -> int: +async def revoke_spire(machine: Machine, *, admin_client: NsecBunkerAdminClient) -> int: """Revoke a spire's bunker access (the "Revoke spire access" UX, aiolabs/spirekeeper#9/#12). @@ -274,6 +274,4 @@ async def revoke_spire( except NsecBunkerNotConfiguredError as exc: raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc except NsecBunkerError as exc: - raise PairingError( - f"bunker admin RPC failed during revoke: {exc}" - ) from exc + raise PairingError(f"bunker admin RPC failed during revoke: {exc}") from exc diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 080f5f9..e3d2999 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -218,17 +218,29 @@ def test_malformed_token_raises(): _pair(bunker) -def test_missing_relay_or_passphrase_raises(): - with pytest.raises(PairingError, match="LNBITS_NSEC_BUNKER_URL"): - asyncio.run( +def test_bunker_relay_defaults_to_spire_event_relay(): + """No explicit bunker_relay -> the relay baked into bunker_url is the spire's + 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( _machine(), relays=_RELAYS, - admin_client=FakeBunker(), - bunker_relay="", + admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret + bunker_relay=empty, 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"): asyncio.run( pair_spire( -- 2.53.0