diff --git a/models.py b/models.py index 99ee552..34db4b6 100644 --- a/models.py +++ b/models.py @@ -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 diff --git a/pairing.py b/pairing.py index 83933e8..a751e1d 100644 --- a/pairing.py +++ b/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) diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index 745bbfe..775fd4f 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -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): diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 1062afd..15334fa 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -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, ) ) diff --git a/views_api.py b/views_api.py index 35d35b2..ebaf53b 100644 --- a/views_api.py +++ b/views_api.py @@ -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: