UNPKG

@waku/discovery

Version:

Contains various discovery mechanisms: DNS Discovery (EIP-1459, Peer Exchange, Local Peer Cache Discovery.

168 lines (146 loc) 5.18 kB
import { ENR, EnrDecoder } from "@waku/enr"; import type { DnsClient, IEnr, SearchContext } from "@waku/interfaces"; import { Logger, shuffle } from "@waku/utils"; import { DnsOverHttps } from "./dns_over_https.js"; import { ENRTree } from "./enrtree.js"; import { fetchNodes } from "./fetch_nodes.js"; const log = new Logger("discovery:dns"); export class DnsNodeDiscovery { private readonly dns: DnsClient; private readonly _DNSTreeCache: { [key: string]: string }; public static async dnsOverHttp( dnsClient?: DnsClient ): Promise<DnsNodeDiscovery> { if (!dnsClient) { dnsClient = await DnsOverHttps.create(); } return new DnsNodeDiscovery(dnsClient); } public constructor(dns: DnsClient) { this._DNSTreeCache = {}; this.dns = dns; } /** * Retrieve the next peers from the passed [[enrTreeUrls]], */ public async *getNextPeer(enrTreeUrls: string[]): AsyncGenerator<IEnr> { // Shuffle the ENR Trees so that not all clients connect to same nodes first. for (const enrTreeUrl of shuffle(enrTreeUrls)) { const { publicKey, domain } = ENRTree.parseTree(enrTreeUrl); const context: SearchContext = { domain, publicKey, visits: {} }; for await (const peer of fetchNodes(() => this._search(domain, context) )) { yield peer; } } } /** * Runs a recursive, randomized descent of the DNS tree to retrieve a single * ENR record as an ENR. Returns null if parsing or DNS resolution fails. */ private async _search( subdomain: string, context: SearchContext ): Promise<ENR | null> { try { const entry = await this._getTXTRecord(subdomain, context); context.visits[subdomain] = true; let next: string; let branches: string[]; const entryType = getEntryType(entry); try { switch (entryType) { case ENRTree.ROOT_PREFIX: next = ENRTree.parseAndVerifyRoot(entry, context.publicKey); return await this._search(next, context); case ENRTree.BRANCH_PREFIX: branches = ENRTree.parseBranch(entry); next = selectRandomPath(branches, context); return await this._search(next, context); case ENRTree.RECORD_PREFIX: return EnrDecoder.fromString(entry); default: return null; } } catch (error) { log.error( `Failed to search DNS tree ${entryType} at subdomain ${subdomain}: ${error}` ); return null; } } catch (error) { log.error( `Failed to retrieve TXT record at subdomain ${subdomain}: ${error}` ); return null; } } /** * Retrieves the TXT record stored at a location from either * this DNS tree cache or via DNS query. * * @throws if the TXT Record contains non-UTF-8 values. */ private async _getTXTRecord( subdomain: string, context: SearchContext ): Promise<string> { if (this._DNSTreeCache[subdomain]) { return this._DNSTreeCache[subdomain]; } // Location is either the top level tree entry host or a subdomain of it. const location = subdomain !== context.domain ? `${subdomain}.${context.domain}` : context.domain; const response = await this.dns.resolveTXT(location); if (!response.length) throw new Error("Received empty result array while fetching TXT record"); if (!response[0].length) throw new Error("Received empty TXT record"); // Branch entries can be an array of strings of comma-delimited subdomains, with // some subdomain strings split across the array elements const result = response.join(""); this._DNSTreeCache[subdomain] = result; return result; } } function getEntryType(entry: string): string { if (entry.startsWith(ENRTree.ROOT_PREFIX)) return ENRTree.ROOT_PREFIX; if (entry.startsWith(ENRTree.BRANCH_PREFIX)) return ENRTree.BRANCH_PREFIX; if (entry.startsWith(ENRTree.RECORD_PREFIX)) return ENRTree.RECORD_PREFIX; return ""; } /** * Returns a randomly selected subdomain string from the list provided by a branch * entry record. * * The client must track subdomains which are already resolved to avoid * going into an infinite loop b/c branch entries can contain * circular references. It’s in the client’s best interest to traverse the * tree in random order. */ function selectRandomPath(branches: string[], context: SearchContext): string { // Identify domains already visited in this traversal of the DNS tree. // Then filter against them to prevent cycles. const circularRefs: { [key: number]: boolean } = {}; for (const [idx, subdomain] of branches.entries()) { if (context.visits[subdomain]) { circularRefs[idx] = true; } } // If all possible paths are circular... if (Object.keys(circularRefs).length === branches.length) { throw new Error("Unresolvable circular path detected"); } // Randomly select a viable path let index; do { index = Math.floor(Math.random() * branches.length); } while (circularRefs[index]); return branches[index]; }