fryorcraken 25f884e05b
feat: retrieve peers from all passed enrtree URLs
Order retrieval is random, but it attemps to retrieve from all ENR trees.
2025-07-29 19:29:20 +10:00

168 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. Its in the clients 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];
}