import * as dns from 'dns' import { EthereumJSErrorWithoutCode } from '@ethereumjs/util' import debugDefault from 'debug' import { ENR } from './enr.ts' import type { Common } from '@ethereumjs/common' import type { DNSOptions, PeerInfo } from '../types.ts' const debug = debugDefault('devp2p:dns:dns') type SearchContext = { domain: string publicKey: string visits: { [key: string]: boolean } } export class DNS { protected _DNSTreeCache: { [key: string]: string } protected readonly _errorTolerance: number = 10 protected _common?: Common private DEBUG: boolean constructor(options: DNSOptions = {}) { this._DNSTreeCache = {} if (typeof options.dnsServerAddress === 'string') { dns.promises.setServers([options.dnsServerAddress]) } this._common = options.common this.DEBUG = typeof window === 'undefined' ? (process?.env?.DEBUG?.includes('ethjs') ?? false) : false } /** * Returns a list of verified peers listed in an EIP-1459 DNS tree. Method may * return fewer peers than requested if `maxQuantity` is larger than the number * of ENR records or the number of errors/duplicate peers encountered by randomized * search exceeds `maxQuantity` plus the `errorTolerance` factor. * * @param {number} maxQuantity max number to get * @param {string[]} dnsNetworks enrTree strings (See EIP-1459 for format) * @return {PeerInfo} */ async getPeers(maxQuantity: number, dnsNetworks: string[]): Promise { let totalSearches: number = 0 const peers: PeerInfo[] = [] const networkIndex = Math.floor(Math.random() * dnsNetworks.length) const { publicKey, domain } = ENR.parseTree(dnsNetworks[networkIndex]) while (peers.length < maxQuantity && totalSearches < maxQuantity + this._errorTolerance) { const context: SearchContext = { domain, publicKey, visits: {}, } const peer = await this._search(domain, context) if (this._isNewPeer(peer, peers)) { peers.push(peer) if (this.DEBUG) { debug(`got new peer candidate from DNS address=${peer.address}`) } } totalSearches++ } return peers } /** * Runs a recursive, randomized descent of the DNS tree to retrieve a single * ENR record as a PeerInfo object. Returns null if parsing or DNS resolution fails. * * @param {string} subdomain * @param {SearchContext} context * @return {PeerInfo | null} */ private async _search(subdomain: string, context: SearchContext): Promise { const entry = await this._getTXTRecord(subdomain, context) context.visits[subdomain] = true let next: string let branches: string[] try { switch (this._getEntryType(entry)) { case ENR.ROOT_PREFIX: next = ENR.parseAndVerifyRoot(entry, context.publicKey, this._common) return await this._search(next, context) case ENR.BRANCH_PREFIX: branches = ENR.parseBranch(entry) next = this._selectRandomPath(branches, context) return await this._search(next, context) case ENR.RECORD_PREFIX: return ENR.parseAndVerifyRecord(entry, this._common) default: return null } } catch (error: any) { if (this.DEBUG) { debug(`Errored searching DNS tree at subdomain ${subdomain}: ${error}`) } return null } } private _getEntryType(entry: string): string { if (entry.startsWith(ENR.ROOT_PREFIX)) return ENR.ROOT_PREFIX if (entry.startsWith(ENR.BRANCH_PREFIX)) return ENR.BRANCH_PREFIX if (entry.startsWith(ENR.RECORD_PREFIX)) return ENR.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. * * @param {string[]} branches * @param {SearchContext} context * @return {String} subdomain */ private _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 EthereumJSErrorWithoutCode('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] } /** * Retrieves the TXT record stored at a location from either * this DNS tree cache or via Node's DNS api * * @param {string} subdomain * @param {SearchContext = {}} context * @return {string} */ private async _getTXTRecord(subdomain: string, context: SearchContext): Promise { 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 dns.promises.resolve(location, 'TXT') if (response.length === 0) throw EthereumJSErrorWithoutCode('Received empty result array while fetching TXT record') if (response[0].length === 0) throw EthereumJSErrorWithoutCode('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 // (e.g btw end of arr[0] and beginning of arr[1]) const result = response[0].length > 1 ? response[0].join('') : response[0][0] this._DNSTreeCache[subdomain] = result return result } /** * Returns false if candidate peer already exists in the * current collection of peers. * Returns true otherwise. * Also acts as a typeguard for peer * * @param {PeerInfo} peer * @param {PeerInfo[]} peers * @return {boolean} */ private _isNewPeer(peer: PeerInfo | null, peers: PeerInfo[]): peer is PeerInfo { if (peer === null || peer.address === undefined) return false for (const existingPeer of peers) { if (peer.address === existingPeer.address) { return false } } return true } /** * Only used for testing. A stopgap to enable successful * TestDouble mocking of the native `dns` module. * @param {any} mock TestDouble fn */ __setNativeDNSModuleResolve(mock: any) { dns.promises.resolve = mock.resolve } }