Files
routstr-core/routstr/nostr/listing.py
T

542 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Listing: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
import asyncio
import json
import os
import random
import time
import unicodedata
from typing import Any, cast
from nostr_sdk import Event
from ..core import get_logger
from ..core.settings import settings
from .sdk import create_signed_event, fetch_events, parse_keypair, send_event
logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from ..core.version import __version__ as imported_version
return imported_version
except Exception:
return None
def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
"""
Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex).
Args:
nsec: Nostr private key in nsec format or hex format
Returns:
Tuple of (private_key_hex, public_key_hex) or None if invalid
"""
try:
if not (nsec.startswith("nsec") or len(nsec) == 64):
logger.error(f"Invalid private key format/length: {len(nsec)}")
return None
return parse_keypair(nsec)
except Exception as e:
logger.error(f"Failed to convert nsec to keypair: {e}")
return None
def create_listing_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
mint_urls: list[str] | None = None,
version: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a listing provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
provider_id: Unique identifier for this provider (d tag)
endpoint_urls: List of URLs to connect to the provider
mint_urls: Optional list of ecash mint URLs for payments
version: Provider software version
metadata: Optional metadata dictionary (name, picture, about, etc.)
Returns:
Complete signed nostr event as a dict ready for publishing
"""
tags = [["d", provider_id]]
for url in endpoint_urls:
tags.append(["u", url])
if mint_urls:
for m in mint_urls:
if m:
tags.append(["mint", m])
if version:
tags.append(["version", version])
content = json.dumps(metadata, separators=(",", ":")) if metadata else ""
return create_signed_event(
private_key_hex,
kind=38421,
content=content,
tags=tags,
)
def _get_tag_values(event: dict[str, Any], key: str) -> list[str]:
tags = event.get("tags", [])
values: list[str] = []
for tag in tags:
if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2:
values.append(tag[1])
return values
def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None:
values = _get_tag_values(event, key)
return values[0] if values else None
def _parse_content_json(content: str) -> dict[str, Any]:
if not content:
return {}
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
if a.get("kind") != b.get("kind"):
return False
if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"):
return False
urls_a = set(_get_tag_values(a, "u"))
urls_b = set(_get_tag_values(b, "u"))
if urls_a != urls_b:
return False
mints_a = set(_get_tag_values(a, "mint"))
mints_b = set(_get_tag_values(b, "mint"))
if mints_a != mints_b:
return False
if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"):
return False
content_a = _parse_content_json(cast(str, a.get("content", "")))
content_b = _parse_content_json(cast(str, b.get("content", "")))
if content_a != content_b:
return False
return True
async def query_listing_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> tuple[list[dict[str, Any]], bool]:
"""
Query a Nostr relay for listing provider announcements (kind:38421) via nostr library.
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
succeeded without transport-level errors.
"""
try:
events_out = await fetch_events(
relay_url,
kind=38421,
author=pubkey,
limit=10,
timeout=timeout,
)
except Exception as e:
logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}")
return [], False
if provider_id is not None:
events_out = [
event
for event in events_out
if _get_single_tag_value(event, "d") == provider_id
]
return events_out, True
def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
"""Discover onion URL by reading Tor hidden service hostname files.
Tries common paths first, then scans recursively for any 'hostname' file.
Returns an http URL like 'http://<host>.onion' if found.
"""
common_candidates = [
os.path.join(base_dir, "hs", "router", "hostname"),
os.path.join(base_dir, "hs", "ROUTER", "hostname"),
os.path.join(base_dir, "hidden_service", "hostname"),
]
for candidate in common_candidates:
try:
with open(candidate, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
pass
try:
for root, _dirs, files in os.walk(base_dir):
if "hostname" in files:
path = os.path.join(root, "hostname")
try:
with open(path, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
continue
except Exception:
pass
return None
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
explicit = settings.provider_id
if explicit:
logger.info(f"Using configured provider_id from env: {explicit}")
return explicit
async def query_single_relay(relay_url: str) -> list[dict[str, Any]]:
try:
events, _ok = await query_listing_events(relay_url, public_key_hex, None)
return events
except Exception:
return []
# Query all relays concurrently
all_events_lists = await asyncio.gather(
*[query_single_relay(relay_url) for relay_url in relay_urls]
)
latest_event: dict[str, Any] | None = None
latest_ts = -1
for events_list in all_events_lists:
for ev in events_list:
ts = int(ev.get("created_at", 0))
if ts > latest_ts:
latest_event = ev
latest_ts = ts
existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None
if existing_d:
logger.info(f"Reusing existing provider_id from relay: {existing_d}")
return existing_d
fallback = public_key_hex[:12]
logger.info(f"No existing provider_id found; using fallback: {fallback}")
return fallback
async def resolve_provider_id_strict(public_key_hex: str, relay_urls: list[str]) -> str:
"""Require a configured or unambiguous signed coordinate for durable stats."""
explicit = settings.provider_id
if explicit:
if len(explicit) > 64 or any(
unicodedata.category(char) == "Cc" for char in explicit
):
raise ValueError("PROVIDER_ID must contain 1 to 64 printable characters")
return explicit
results = await asyncio.gather(
*(query_listing_events(url, public_key_hex) for url in relay_urls),
return_exceptions=True,
)
candidates: set[str] = set()
for result in results:
if isinstance(result, BaseException):
raise ValueError("Configure PROVIDER_ID while listing relays are unavailable")
events, ok = result
if not ok or len(events) >= 10:
raise ValueError("Configure PROVIDER_ID when listing history is incomplete")
for event in events:
try:
signed = Event.from_json(json.dumps(event))
if not signed.verify() or event.get("pubkey") != public_key_hex:
continue
values = _get_tag_values(event, "d")
if event.get("kind") == 38421 and len(values) == 1 and values[0]:
candidates.add(values[0])
except Exception:
continue
if len(candidates) != 1:
raise ValueError("Configure PROVIDER_ID to select one provider for public stats")
return next(iter(candidates))
async def publish_to_relay(
relay_url: str,
event: dict[str, Any],
timeout: int = 30,
) -> bool:
"""
Publish a listing event to a nostr relay via nostr library.
"""
try:
await send_event(relay_url, event, timeout=timeout)
logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}")
return True
except Exception as e:
logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}")
return False
# Re-announce cadence once a provider is listed.
ANNOUNCEMENT_INTERVAL_SECONDS = 24 * 60 * 60
# Poll cadence while there is nothing to announce (no NSEC, no endpoint, ...).
DISABLED_POLL_SECONDS = 60
# How often the long re-announce sleep re-checks the configured NSEC, so a
# newly saved identity is announced promptly instead of up to 24h later.
IDENTITY_POLL_SECONDS = 30
DEFAULT_RELAY_URLS = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
def _resolve_endpoint_urls() -> list[str]:
"""Endpoints to advertise: a public HTTP URL and/or an onion URL."""
endpoint_urls: list[str] = []
base_url = (settings.http_url or "").strip()
if base_url and base_url != "http://localhost:8000":
endpoint_urls.append(base_url)
onion_url = (settings.onion_url or "").strip()
if not onion_url:
discovered = discover_onion_url_from_tor()
if discovered:
onion_url = discovered
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
if onion_url:
if onion_url.endswith(".onion") and not (
onion_url.startswith("http://") or onion_url.startswith("https://")
):
onion_url = f"http://{onion_url}"
endpoint_urls.append(onion_url)
return endpoint_urls
def _resolve_relay_urls() -> list[str]:
relay_urls = [u.strip() for u in getattr(settings, "relays", []) if u.strip()]
return relay_urls or list(DEFAULT_RELAY_URLS)
def _resolve_mint_urls() -> list[str] | None:
mints = [m.strip() for m in (settings.cashu_mints or []) if m.strip()]
return mints or None
async def _sleep_until_next_announcement(
seconds: float, parsed_nsec: str | None
) -> None:
"""Sleep up to ``seconds``, returning early if the configured NSEC changes.
Without the early wake, a node runner who replaces the NSEC in the admin UI
would wait out the whole re-announce interval before the new identity (and,
with it, the new ``d`` tag and npub) is announced.
"""
remaining = float(seconds)
while remaining > 0:
if (settings.nsec or "").strip() != (parsed_nsec or ""):
return
chunk = min(float(IDENTITY_POLL_SECONDS), remaining)
await asyncio.sleep(chunk)
remaining -= chunk
async def announce_provider() -> None:
"""Background task announcing this Routstr provider to Nostr relays.
Started unconditionally at boot: while the node has no NSEC the task idles
and re-checks, so an identity configured later through the admin UI is
picked up (and announced) without a restart. The identity, endpoints, mints
and relays are all re-read every iteration, mirroring
``publish_usage_analytics``.
"""
parsed_nsec: str | None = None
private_key_hex: str | None = None
public_key_hex: str | None = None
provider_id: str | None = None
warned_missing_nsec = False
# Backoff state is deliberately long-lived: it has to survive an idle poll,
# a full re-announce cycle and an identity change, otherwise a failing relay
# would be retried at full rate on every pass.
backoff_base = 5.0
backoff_max = 900.0
backoff_jitter_ratio = 0.2
relay_next_allowed: dict[str, float] = {}
relay_current_delay: dict[str, float] = {}
def _should_skip(relay: str) -> bool:
return time.time() < relay_next_allowed.get(relay, 0.0)
def _register_success(relay: str) -> None:
relay_current_delay[relay] = 0.0
relay_next_allowed[relay] = time.time()
def _register_failure(relay: str) -> None:
previous = relay_current_delay.get(relay, 0.0)
delay = backoff_base if previous <= 0.0 else min(backoff_max, previous * 2.0)
jitter = delay * backoff_jitter_ratio * (2.0 * random.random() - 1.0)
scheduled = time.time() + max(0.0, delay + jitter)
relay_current_delay[relay] = delay
relay_next_allowed[relay] = scheduled
logger.debug(
f"Backoff: {relay} delay={delay:.1f}s jitter={jitter:.1f}s next={int(scheduled)}"
)
while True:
try:
nsec = (settings.nsec or "").strip()
if not nsec:
if not warned_missing_nsec:
logger.info(
"Nostr private key not configured (NSEC); waiting for one "
"to be set before announcing this provider"
)
warned_missing_nsec = True
parsed_nsec = None
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
# Re-derive the identity whenever the configured NSEC changes, so a
# key saved (or replaced) through the admin UI takes effect live.
if nsec != parsed_nsec:
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error(
"Invalid NSEC; waiting for a valid one before announcing"
)
parsed_nsec = None
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
private_key_hex, public_key_hex = keypair
parsed_nsec = nsec
provider_id = None
warned_missing_nsec = False
logger.info(f"Using Nostr pubkey: {public_key_hex}")
if private_key_hex is None or public_key_hex is None:
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
endpoint_urls = _resolve_endpoint_urls()
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). "
"Skipping listing publish until one is set."
)
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
relay_urls = _resolve_relay_urls()
if provider_id is None:
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
metadata = {
"name": settings.name or "Routstr Proxy",
"about": settings.description
or "Privacy-preserving AI proxy via Nostr",
}
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=_resolve_mint_urls(),
version=get_app_version(),
metadata=metadata,
)
# Fetch existing events for this provider_id
existing_events: list[dict[str, Any]] = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_listing_events(
relay_url, public_key_hex, provider_id
)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if all_match:
logger.debug(
"Matching listing announcement already present; skipping publish"
)
else:
logger.debug(
"No matching listing announcement found or differences "
"detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
if await publish_to_relay(relay_url, candidate_event):
_register_success(relay_url)
success_count += 1
else:
_register_failure(relay_url)
logger.info(
"Published listing announcement to "
f"{success_count}/{len(relay_urls)} relays"
)
# Re-announce periodically; wakes early if the NSEC changes.
await _sleep_until_next_announcement(
ANNOUNCEMENT_INTERVAL_SECONDS, parsed_nsec
)
except asyncio.CancelledError:
logger.info("Listing announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in listing announcement loop: {type(e).__name__}")
await asyncio.sleep(DISABLED_POLL_SECONDS)