diff --git a/filter_bitchat_relays.sh b/filter_bitchat_relays.sh new file mode 100755 index 0000000..7e0639d --- /dev/null +++ b/filter_bitchat_relays.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# How many relays to query in parallel +CONCURRENCY=${CONCURRENCY:-10} + +# Read relays from stdin or file into array +mapfile -t relays + +# Function to test a single relay for kind 20000 support (both read and write) +test_relay() { + local relay="$1" + local count + local post_result + + # Test 1: Try to request kind 20000 events and count them + # Use timeout to avoid hanging on unresponsive relays + count=$(timeout 10s nak req -k 20000 "$relay" 2>/dev/null | jq -s 'length' 2>/dev/null) + + # Check if we got a valid response (number >= 0) for reading + if [[ ! "$count" =~ ^[0-9]+$ ]]; then + return 1 # Failed to read kind 20000 events + fi + + # Test 2: Try to post a kind 20000 event + # Generate a unique test content to avoid duplicate events + local test_content="test_$(date +%s)_$$" + + # Try to post a test kind 20000 event + post_result=$(timeout 10s nak event -k 20000 --tag n=test --tag g=test --content "$test_content" "$relay" 2>&1) + + # Check if the post was successful + if echo "$post_result" | grep -q "success"; then + echo "$relay" + else + return 1 + fi +} + +export -f test_relay + +total_relays=${#relays[@]} +batch_size=$((total_relays / CONCURRENCY)) +if [ $batch_size -eq 0 ]; then + batch_size=1 +fi + +# Process relays in parallel batches +printf '%s\n' "${relays[@]}" | xargs -P "$CONCURRENCY" -I {} bash -c 'test_relay "$@"' _ {} \ No newline at end of file diff --git a/nostr_relay_discovery.py b/nostr_relay_discovery.py new file mode 100644 index 0000000..f22aa0e --- /dev/null +++ b/nostr_relay_discovery.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +""" +Nostr Relay Discovery Tool + +This tool discovers Nostr relays by performing breadth-first search through +follow lists (kind 3 events) and analyzing relay tags to build a network map. +""" + +import asyncio +import json +import logging +import re +import time +import argparse +import secrets +import base64 +from collections import deque +from dataclasses import dataclass, field +from typing import Set, List, Dict, Optional, Tuple +from urllib.parse import urlparse +import websockets +import websockets.exceptions + + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Save progress every N relays processed +SAVE_POINT = 10 + + +@dataclass +class RelayDiscoveryStats: + """Statistics for the relay discovery process""" + total_relays_found: int = 0 + functioning_relays: int = 0 + events_processed: int = 0 + existing_relays_verified: int = 0 + existing_relays_failed: int = 0 + start_time: float = field(default_factory=time.time) + + def print_stats(self): + """Print current statistics""" + elapsed = time.time() - self.start_time + print(f"\n=== Discovery Statistics ===") + print(f"Elapsed time: {elapsed:.2f} seconds") + print(f"Total relays found: {self.total_relays_found}") + print(f"Functioning relays: {self.functioning_relays}") + print(f"Events processed: {self.events_processed}") + if self.existing_relays_verified > 0 or self.existing_relays_failed > 0: + print(f"Existing relays verified: {self.existing_relays_verified}") + print(f"Existing relays failed verification: {self.existing_relays_failed}") + print(f"Success rate: {(self.functioning_relays/max(1, self.total_relays_found)*100):.1f}%") + + +class NostrRelayDiscovery: + """Nostr relay discovery tool using breadth-first search through follow lists""" + + def __init__(self, initial_relay: str, max_depth: int = 3, connection_timeout: int = 10, output_file: str = "relay_discovery_results.json", save_point: int = SAVE_POINT): + self.initial_relay = initial_relay + self.max_depth = max_depth + self.connection_timeout = connection_timeout + self.output_file = output_file + self.save_point = save_point + + # Discovery state + self.to_visit: deque = deque() # (relay_url, depth) - will be populated by load_existing_results + self.to_visit_set: Set[str] = set() + self.visited_relays: Set[str] = set() + self.functioning_relays: Set[str] = set() + + # Statistics + self.stats = RelayDiscoveryStats() + + # Regex for validating relay URLs + self.relay_url_pattern = re.compile( + r'^wss?://[a-zA-Z0-9.-]+(?:\:[0-9]+)?(?:/[a-zA-Z0-9._~:/?#[\]@!$&\'()*+,;=-]*)?$' + ) + + def is_valid_relay_url(self, url: str) -> bool: + """Validate if a URL looks like a valid relay URL""" + if not url or not isinstance(url, str): + return False + + # Clean up the URL + url = url.strip() + + # Check basic format + if not self.relay_url_pattern.match(url): + return False + + # Parse and validate + try: + parsed = urlparse(url) + return ( + parsed.scheme in ['ws', 'wss'] and + parsed.netloc and + len(parsed.netloc) > 0 + ) + except Exception: + return False + + def normalize_relay_url(self, url: str) -> str: + """Normalize relay URL (remove trailing slashes, etc.)""" + url = url.strip() + match = re.match(r'^(wss://[^/]+)/?$', url) + + if match: + matched_url = match.group(1) + logger.debug(f"matched url: {matched_url}") + return matched_url + else: + return '' + + def generate_subscription_id(self) -> str: + """Generate a random subscription ID using 16 random bytes encoded as base64""" + random_bytes = secrets.token_bytes(16) + return base64.b64encode(random_bytes).decode() + + async def test_relay_connection(self, relay_url: str) -> bool: + """Test if a relay is functioning by attempting to connect and validate Nostr protocol responses""" + try: + logger.debug(f"Testing connection to {relay_url}") + + async with websockets.connect( + relay_url, + timeout=self.connection_timeout, + max_size=2**20, # 1MB max message size + ping_interval=None # Disable ping + ) as websocket: + # Send a simple REQ to test functionality + subscription_id = self.generate_subscription_id() + test_filter = { + "kinds": [1], + "limit": 1 + } + req_msg = ["REQ", subscription_id, test_filter] + await websocket.send(json.dumps(req_msg)) + logger.debug(f"Sent REQ with subscription ID: {subscription_id}") + + # Wait for a response and validate it's a proper Nostr protocol message + try: + response = await asyncio.wait_for(websocket.recv(), timeout=5.0) + logger.debug(f"Received response: {response[:200]}...") # Log first 200 chars + + try: + data = json.loads(response) + + # Check if it's a valid Nostr protocol message + if not isinstance(data, list) or len(data) < 2: + logger.debug(f"Invalid Nostr message format from {relay_url}: not a list or too short") + return False + + message_type = data[0] + message_subscription_id = data[1] + + # Validate subscription ID matches + if message_subscription_id != subscription_id: + logger.debug(f"Subscription ID mismatch from {relay_url}: expected {subscription_id}, got {message_subscription_id}") + return False + + # Check for valid Nostr protocol message types + if message_type == "EVENT": + logger.debug(f"✓ Received EVENT from {relay_url}") + # Send CLOSE to clean up + close_msg = ["CLOSE", subscription_id] + await websocket.send(json.dumps(close_msg)) + return True + elif message_type == "EOSE": + logger.warning(f"Received EOSE immediately from {relay_url} - relay has no events (unusual)") + # Send CLOSE to clean up + close_msg = ["CLOSE", subscription_id] + await websocket.send(json.dumps(close_msg)) + return True + elif message_type == "NOTICE": + notice_message = data[2] if len(data) > 2 else "unknown" + logger.debug(f"Received NOTICE from {relay_url}: {notice_message}") + return False + else: + logger.debug(f"Unexpected message type from {relay_url}: {message_type}") + return False + + except json.JSONDecodeError as e: + logger.debug(f"Invalid JSON response from {relay_url}: {e}") + return False + + except asyncio.TimeoutError: + logger.debug(f"Timeout waiting for response from {relay_url}") + return False + + except Exception as e: + logger.debug(f"Failed to connect to {relay_url}: {e}") + return False + + async def fetch_follow_lists(self, relay_url: str) -> List[Dict]: + """Fetch kind 3 events (follow lists) from a relay""" + follow_events = [] + + try: + logger.info(f"Fetching follow lists from {relay_url}") + + async with websockets.connect( + relay_url, + timeout=self.connection_timeout, + max_size=2**20 + ) as websocket: + # Request follow lists (kind 3 events) + filter_req = { + "kinds": [3], + "limit": 300, + } + + subscription_id = self.generate_subscription_id() + req_msg = ["REQ", subscription_id, filter_req] + + await websocket.send(json.dumps(req_msg)) + logger.debug(f"Sent request: {req_msg}") + + # Collect events until EOSE + start_time = time.time() + timeout_duration = 30.0 # 30 second timeout + + while time.time() - start_time < timeout_duration: + try: + message = await asyncio.wait_for(websocket.recv(), timeout=5.0) + data = json.loads(message) + + if data[0] == "EVENT" and data[1] == subscription_id: + event = data[2] + if event.get("kind") == 3: + follow_events.append(event) + logger.debug(f"Collected follow event from {event.get('pubkey', 'unknown')[:8]}...") + + elif data[0] == "EOSE" and data[1] == subscription_id: + logger.debug(f"Received EOSE for {subscription_id}") + break + + except asyncio.TimeoutError: + continue + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse message from {relay_url}: {e}") + continue + + # Send CLOSE message + close_msg = ["CLOSE", subscription_id] + await websocket.send(json.dumps(close_msg)) + + except Exception as e: + logger.error(f"Error fetching follow lists from {relay_url}: {e}") + + logger.info(f"Collected {len(follow_events)} follow events from {relay_url}") + return follow_events + + def extract_relays_from_events(self, events: List[Dict]) -> Set[str]: + """Extract relay URLs from follow list events""" + relay_urls = set() + + for event in events: + self.stats.events_processed += 1 + + # Process tags to find relay information + tags = event.get('tags', []) + + for tag in tags: + if not isinstance(tag, list) or len(tag) < 2: + continue + + # Look for 'r' tags (relay tags) + if tag[0] == 'r' and len(tag) >= 2: + potential_relay = tag[1] + if self.is_valid_relay_url(potential_relay): + normalized_url = self.normalize_relay_url(potential_relay) + relay_urls.add(normalized_url) + + # Also check 'p' tags for potential relay info in some implementations + elif tag[0] == 'p' and len(tag) >= 3: + # Some implementations put relay info in the 3rd element of p tags + if len(tag) > 2 and self.is_valid_relay_url(tag[2]): + normalized_url = self.normalize_relay_url(tag[2]) + relay_urls.add(normalized_url) + + return relay_urls + + async def load_existing_results(self) -> bool: + """Load existing results and verify that functioning relays still work""" + import os + + if not os.path.exists(self.output_file): + logger.info(f"No existing results file found at {self.output_file}, starting fresh") + # Initialize with just the initial relay + self.to_visit.append((self.initial_relay, 0)) + self.to_visit_set.add(self.initial_relay) + return False + + try: + logger.info(f"Loading existing results from {self.output_file}") + with open(self.output_file, 'r') as f: + data = json.load(f) + + existing_relays = data.get('functioning_relays', []) + logger.info(f"Found {len(existing_relays)} existing functioning relays to verify") + + if not existing_relays: + logger.info("No existing functioning relays found, starting fresh") + self.to_visit.append((self.initial_relay, 0)) + self.to_visit_set.add(self.initial_relay) + return False + + # Verify existing relays still work + verified_relays = await self.verify_existing_relays(existing_relays) + + if verified_relays: + logger.info(f"Successfully verified {len(verified_relays)} existing relays") + # Use verified relays as starting points for discovery + for relay in verified_relays: + self.to_visit.append((relay, 0)) + self.to_visit_set.add(relay) + self.functioning_relays.add(relay) + self.stats.functioning_relays += 1 + + # Also preserve other statistics if available + if 'statistics' in data: + old_stats = data['statistics'] + self.stats.total_relays_found = old_stats.get('total_relays_found', len(verified_relays)) + self.stats.events_processed = old_stats.get('events_processed', 0) + + return True + else: + logger.warning("No existing relays could be verified, starting fresh") + self.to_visit.append((self.initial_relay, 0)) + self.to_visit_set.add(self.initial_relay) + return False + + except Exception as e: + logger.error(f"Error loading existing results: {e}") + logger.info("Starting fresh due to error") + self.to_visit.append((self.initial_relay, 0)) + self.to_visit_set.add(self.initial_relay) + return False + + async def verify_existing_relays(self, existing_relays: List[str]) -> List[str]: + """Verify that existing relays still work and filter out non-working ones""" + verified_relays = [] + + logger.info(f"Verifying {len(existing_relays)} existing relays...") + + # Test relays in batches to avoid overwhelming connections + batch_size = 10 + for i in range(0, len(existing_relays), batch_size): + batch = existing_relays[i:i + batch_size] + batch_tasks = [] + + for relay in batch: + if self.is_valid_relay_url(relay): + batch_tasks.append(self.test_relay_connection(relay)) + else: + logger.warning(f"Invalid relay URL in existing results: {relay}") + self.stats.existing_relays_failed += 1 + + if batch_tasks: + # Run batch tests concurrently + batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) + + for j, result in enumerate(batch_results): + relay = batch[j] + if isinstance(result, bool) and result: + verified_relays.append(relay) + self.stats.existing_relays_verified += 1 + logger.info(f"✓ Verified existing relay: {relay}") + else: + self.stats.existing_relays_failed += 1 + logger.warning(f"✗ Existing relay failed verification: {relay}") + + # Small delay between batches to be nice to the relays + if i + batch_size < len(existing_relays): + await asyncio.sleep(0.5) + + logger.info(f"Verification complete: {len(verified_relays)}/{len(existing_relays)} relays still functioning") + return verified_relays + + async def discover_relays(self) -> Set[str]: + """Main discovery method using breadth-first search""" + # First, try to load existing results and verify them + logger.info("Checking for existing results to build upon...") + await self.load_existing_results() + + logger.info(f"Starting relay discovery from existing verified relays or {self.initial_relay}") + logger.info(f"Maximum depth: {self.max_depth}") + + while self.to_visit: + current_relay, depth = self.to_visit.popleft() + self.to_visit_set.remove(current_relay) + + # Skip if already visited + if current_relay in self.visited_relays: + continue + + # Skip if depth exceeds maximum + if depth > self.max_depth: + logger.info(f"Reached maximum depth {self.max_depth}, stopping exploration") + continue + + logger.info(f"Processing relay {current_relay} at depth {depth}") + self.visited_relays.add(current_relay) + + if await self.test_relay_connection(current_relay): + logger.info(f"✓ Relay {current_relay} is functioning") + self.functioning_relays.add(current_relay) + self.stats.functioning_relays += 1 + + # Only fetch follow lists if we haven't reached max depth + if depth < self.max_depth: + try: + # Fetch follow lists from this relay + follow_events = await self.fetch_follow_lists(current_relay) + + if follow_events: + # Extract new relays from follow lists + new_relays = self.extract_relays_from_events(follow_events) + + # Add new relays to visit queue for next depth level + for relay_url in new_relays: + if relay_url not in self.visited_relays and relay_url not in self.to_visit_set: + self.to_visit.append((relay_url, depth + 1)) + self.to_visit_set.add(relay_url) + self.stats.total_relays_found += 1 + logger.debug(f"Added {relay_url} to visit queue at depth {depth + 1}") + + except Exception as e: + logger.error(f"Error processing relay {current_relay}: {e}") + + else: + logger.warning(f"✗ Relay {current_relay} is not functioning") + + # Save progress periodically every save_point relays processed + if len(self.visited_relays) % self.save_point == 0: + logger.info(f"Progress checkpoint: Saving results after processing {len(self.visited_relays)} relays") + self.save_results() + + # Print periodic statistics + if len(self.visited_relays) % 10 == 0: + self.stats.print_stats() + + logger.info("Discovery completed!") + return self.functioning_relays + + def save_results(self, output_file: str = None): + """Save discovery results to a JSON file""" + if output_file is None: + output_file = self.output_file + + results = { + "discovery_settings": { + "initial_relay": self.initial_relay, + "max_depth": self.max_depth, + "connection_timeout": self.connection_timeout, + "save_point": self.save_point + }, + "progress_info": { + "relays_processed": len(self.visited_relays), + "relays_remaining": len(self.to_visit), + "discovery_complete": len(self.to_visit) == 0, + "last_saved": time.time() + }, + "statistics": { + "total_relays_found": self.stats.total_relays_found, + "functioning_relays_count": len(self.functioning_relays), + "events_processed": self.stats.events_processed, + "existing_relays_verified": self.stats.existing_relays_verified, + "existing_relays_failed": self.stats.existing_relays_failed, + "discovery_duration": time.time() - self.stats.start_time + }, + "functioning_relays": list(self.functioning_relays) + } + + with open(output_file, 'w') as f: + json.dump(results, f, indent=2) + + logger.info(f"Results saved to {output_file}") + + +async def main(): + """Main function""" + parser = argparse.ArgumentParser(description="Discover Nostr relays using breadth-first search") + parser.add_argument( + "initial_relay", + help="Initial relay URL to start discovery from (e.g., wss://relay.damus.io)" + ) + parser.add_argument( + "--max-depth", + type=int, + default=3, + help="Maximum depth for breadth-first search (default: 3)" + ) + parser.add_argument( + "--timeout", + type=int, + default=10, + help="Connection timeout in seconds (default: 10)" + ) + parser.add_argument( + "--output", + default="relay_discovery_results.json", + help="Output file for results (default: relay_discovery_results.json)" + ) + parser.add_argument( + "--save-point", + type=int, + default=SAVE_POINT, + help=f"Save progress every N relays processed (default: {SAVE_POINT})" + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Enable verbose logging" + ) + + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + # Validate initial relay URL + discovery = NostrRelayDiscovery(args.initial_relay, args.max_depth, args.timeout, args.output, args.save_point) + if not discovery.is_valid_relay_url(args.initial_relay): + print(f"Error: Invalid relay URL: {args.initial_relay}") + print("Relay URL should start with ws:// or wss://") + return 1 + + try: + # Run discovery + functioning_relays = await discovery.discover_relays() + + # Print final results + print("\n=== DISCOVERY COMPLETED ===") + discovery.stats.print_stats() + + print(f"\n=== FUNCTIONING RELAYS ({len(functioning_relays)}) ===") + for relay in sorted(functioning_relays): + print(f" {relay}") + + # Save results + discovery.save_results() + + return 0 + + except KeyboardInterrupt: + print("\nDiscovery interrupted by user") + discovery.save_results() + return 1 + except Exception as e: + logger.error(f"Discovery failed: {e}") + return 1 + + +if __name__ == "__main__": + import sys + sys.exit(asyncio.run(main())) diff --git a/relay_discovery_results.json b/relay_discovery_results.json new file mode 100644 index 0000000..63e8e4b --- /dev/null +++ b/relay_discovery_results.json @@ -0,0 +1,258 @@ +{ + "discovery_settings": { + "initial_relay": "wss://relay.damus.io", + "max_depth": 3, + "connection_timeout": 10, + "save_point": 10 + }, + "progress_info": { + "relays_processed": 723, + "relays_remaining": 0, + "discovery_complete": true, + "last_saved": 1756039994.605474 + }, + "statistics": { + "total_relays_found": 1200, + "functioning_relays_count": 234, + "events_processed": 69208, + "existing_relays_verified": 219, + "existing_relays_failed": 5, + "discovery_duration": 2709.6405580043793 + }, + "functioning_relays": [ + "wss://relay.hodl.ar", + "wss://at.nostrworks.com", + "wss://relay.pleb.to", + "wss://nostr.reckless.dev", + "wss://no.str.cr", + "wss://relay.usefusion.ai", + "wss://noornode.nostr1.com", + "wss://purplerelay.com", + "wss://xmr.usenostr.org", + "wss://nostr-pub.wellorder.net", + "wss://nostr-dev.wellorder.net", + "wss://nostr.lopp.social", + "wss://bitcoinmaximalists.online", + "wss://adre.su", + "wss://yabu.me", + "wss://nostr.256k1.dev", + "wss://nostr.hashbang.nl", + "wss://relay.geyser.fund", + "wss://thebarn.nostr1.com", + "wss://n.ok0.org", + "wss://nostr.zbd.gg", + "wss://relay.olas.app", + "wss://nostrue.com", + "wss://nostr.1sat.org", + "wss://asia.azzamo.net", + "wss://nostrelites.org", + "wss://kitchen.zap.cooking", + "wss://noxir.kpherox.dev", + "wss://christpill.nostr1.com", + "wss://relay.nos.social", + "wss://lightningrelay.com", + "wss://wot.brightbolt.net", + "wss://relay.bitcoinpark.com", + "wss://relay.westernbtc.com", + "wss://nos.lol", + "wss://relay.235421.xyz", + "wss://relay.minibits.cash", + "wss://us.nostr.wine", + "wss://nostr.mom", + "wss://njump.me", + "wss://f7z.io", + "wss://chronicle.dtonon.com", + "wss://haven.calva.dev", + "wss://paid.nostrified.org", + "wss://frens.nostr1.com", + "wss://hbr.coracle.social", + "wss://nostr.wine", + "wss://nostr.sathoarder.com", + "wss://relay.minds.com", + "wss://relay.bullishbounty.com", + "wss://nfrelay.app", + "wss://relay.primal.net", + "wss://profiles.nostr1.com", + "wss://fiatjaf.nostr1.com", + "wss://zap.watch", + "wss://nostr.middling.mydns.jp", + "wss://nostr.azzamo.net", + "wss://nostr.kungfu-g.rip", + "wss://purplepag.es", + "wss://nostrelay.circum.space", + "wss://nostr.data.haus", + "wss://relay.nostr.wirednet.jp", + "wss://wot.nostr.net", + "wss://relay.benthecarman.com", + "wss://wot.sebastix.social", + "wss://relay.beta.fogtype.com", + "wss://cellar.nostr.wine", + "wss://relay.drss.io", + "wss://relay.lawallet.ar", + "wss://nostr-relay.bitcoin.ninja", + "wss://nostr.thank.eu", + "wss://nostr.xmr.rocks", + "wss://nostr.pareto.space", + "wss://nostr.4rs.nl", + "wss://relay1.nostrchat.io", + "wss://nostr.stakey.net", + "wss://relay.dergigi.com", + "wss://nostr.sovbit.host", + "wss://relay.dwadziesciajeden.pl", + "wss://premium.primal.net", + "wss://nostr21.com", + "wss://nostr.chaima.info", + "wss://nostr.cloud.vinney.xyz", + "wss://nostr.roundrockbitcoiners.com", + "wss://relay.livefreebtc.dev", + "wss://nostr.d11n.net", + "wss://frjosh.nostr1.com", + "wss://nostr.sebastix.dev", + "wss://relay.lexingtonbitcoin.org", + "wss://relay.satlantis.io", + "wss://nostr.bitpunk.fm", + "wss://pyramid.fiatjaf.com", + "wss://relay.nostromo.social", + "wss://nostr-verified.wellorder.net", + "wss://nostr-01.yakihonne.com", + "wss://nostr.bitcoinist.org", + "wss://zaplab.nostr1.com", + "wss://articles.layer3.news", + "wss://relay.g1sms.fr", + "wss://relay.patrickulrich.com", + "wss://nostr.slothy.win", + "wss://knostr.neutrine.com", + "wss://relay.bitdevs.tw", + "wss://relay.jthecodemonkey.xyz", + "wss://relay.damus.io", + "wss://inbox.azzamo.net", + "wss://nostr.spicyz.io", + "wss://thecitadel.nostr1.com", + "wss://chadf.nostr1.com", + "wss://wot.nostr.party", + "wss://relay.nosflare.com", + "wss://aegis.relayted.de", + "wss://relay.nostr.wf", + "wss://nostr-03.dorafactory.org", + "wss://relay.nostrr.de", + "wss://nostr.bilthon.dev", + "wss://slick.mjex.me", + "wss://nostr.yael.at", + "wss://yestr.me", + "wss://hivetalk.nostr1.com", + "wss://relay.gasteazi.net", + "wss://relay.notoshi.win", + "wss://bitcoiner.social", + "wss://relay.nostr.sc", + "wss://wot.dtonon.com", + "wss://nostr.cizmar.net", + "wss://relay.guggero.org", + "wss://nostr.jcloud.es", + "wss://inbox.relays.land", + "wss://nostr.malin.onl", + "wss://nostr-02.dorafactory.org", + "wss://nostr-02.yakihonne.com", + "wss://relay.mostro.network", + "wss://pareto.nostr1.com", + "wss://relay.angor.io", + "wss://nostr.ussenterprise.xyz", + "wss://relay.weloveit.info", + "wss://relay.copylaradio.com", + "wss://offchain.pub", + "wss://relay.nostrdam.com", + "wss://wons.calva.dev", + "wss://welcome.nostr.wine", + "wss://carlos-cdb.top", + "wss://a.nos.lol", + "wss://relay.nostr.vet", + "wss://relay.lumina.rocks", + "wss://nostr.easydns.ca", + "wss://search.nos.today", + "wss://nostr.bitcoiner.social", + "wss://relay.ziomc.com", + "wss://relay.vanderwarker.family", + "wss://orangesync.tech", + "wss://relay.wellorder.net", + "wss://relay.noswhere.com", + "wss://ithurtswhenip.ee", + "wss://relay.azzamo.net", + "wss://relay.nostr.net", + "wss://nostr.0x7e.xyz", + "wss://nrelay.c-stellar.net", + "wss://Relay.Damus.io", + "wss://relay.nostrplebs.com", + "wss://nostr.hekster.org", + "wss://nostr.massmux.com", + "wss://nostr.oxtr.dev", + "wss://paid.no.str.cr", + "wss://mleku.nostr1.com", + "wss://relay.jeffg.fyi", + "wss://140.f7z.io", + "wss://wot.sudocarlos.com", + "wss://srtrelay.c-stellar.net", + "wss://nostr.1f52b.xyz", + "wss://relay.ingwie.me", + "wss://wot.codingarena.top", + "wss://nostr.liberty.fans", + "wss://relay.nostr.band", + "wss://nostr.plantroon.com", + "wss://bots.utxo.one", + "wss://fanfares.nostr1.com", + "wss://nostr.pjv.me", + "wss://nostr.decentony.com", + "wss://wot.downisontheup.ca", + "wss://relay.barine.co", + "wss://theforest.nostr1.com", + "wss://wheat.happytavern.co", + "wss://wot.girino.org", + "wss://relay.orangepill.ovh", + "wss://nostr-relay.cbrx.io", + "wss://nostr-1.nbo.angani.co", + "wss://nostr-relay.derekross.me", + "wss://haven.accioly.social", + "wss://btc.klendazu.com", + "wss://relay.chakany.systems", + "wss://relay.coinos.io", + "wss://relay.exit.pub", + "wss://user.kindpag.es", + "wss://nostr.vulpem.com", + "wss://freelay.sovbit.host", + "wss://nostr.notribe.net", + "wss://wot.nostr.sats4.life", + "wss://nostrja-kari.heguro.com", + "wss://relay.mostr.pub", + "wss://nostr.pareto.town", + "wss://haven.slidestr.net", + "wss://support.nostr1.com", + "wss://vitor.nostr1.com", + "wss://relay.nostr.com.au", + "wss://relay.noderunners.network", + "wss://relay.artx.market", + "wss://relay.nostrhub.fr", + "wss://relay.magiccity.live", + "wss://cyberspace.nostr1.com", + "wss://vault.iris.to", + "wss://hist.nostr.land", + "wss://soloco.nl", + "wss://relay.wavlake.com", + "wss://relay.vertexlab.io", + "wss://relay.theuncounted.site", + "wss://primus.nostr1.com", + "wss://news.utxo.one", + "wss://wot.danieldaquino.me", + "wss://relay.nostrified.org", + "wss://nproxy.kristapsk.lv", + "wss://relay.utxo.one", + "wss://nostr.einundzwanzig.space", + "wss://relay.verified-nostr.com", + "wss://directory.yabu.me", + "wss://strfry.openhoofd.nl", + "wss://jellyfish.land", + "wss://xmr.ithurtswhenip.ee", + "wss://nostr.app.runonflux.io", + "wss://nostr.hifish.org", + "wss://satsage.xyz", + "wss://relay.reya.su", + "wss://nostr.huszonegy.world" + ] +} \ No newline at end of file diff --git a/relays_geo_lookup.sh b/relays_geo_lookup.sh new file mode 100755 index 0000000..6b1ddcb --- /dev/null +++ b/relays_geo_lookup.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Check if the output file is provided +if [ "$#" -ne 1 ]; then + echo "Usage: $0 output.csv" + exit 1 +fi + +output_file="$1" +if [ ! -f "$output_file" ] || [ ! -s "$output_file" ]; then + echo "Relay URL,Latitude,Longitude" > "$output_file" +fi + +while IFS= read -r url; do + + a_records=$(dig +short "$url" A) + + if [[ -n "$a_records" ]]; then + + for ip in $a_records; do + echo "Attempting geo-location lookup for $url -> $ip" + + + location_data="" + retries=3 + attempt=0 + + while [[ -z "$location_data" && $attempt -lt $retries ]]; do + location_data=$(curl -s "https://ipinfo.io/$ip/json") + #echo "location data: $(echo $location_data | jq)" + attempt=$((attempt + 1)) + sleep 1 + done + + latitude=$(echo "$location_data" | jq -r '.loc' | cut -d',' -f1) + longitude=$(echo "$location_data" | jq -r '.loc' | cut -d',' -f2) + + if [[ -n "$latitude" && -n "$longitude" && $latitude != "null" && $longitude != "null" ]]; then + echo "$url: latitude=$latitude, longitude=$longitude" + echo "$url,$latitude,$longitude" >> "$output_file" + break + else + echo "geolocation failed for $url" + continue + fi + done + else + echo "geolocation failed for $url" + fi +done + +echo "Results written to $output_file" +