2022-02-04 14:12:00 +11:00
|
|
|
|
import assert from "assert";
|
2022-01-13 11:33:26 +11:00
|
|
|
|
|
2022-02-04 14:12:00 +11:00
|
|
|
|
import { debug } from "debug";
|
2022-01-13 11:33:26 +11:00
|
|
|
|
|
2022-02-04 14:12:00 +11:00
|
|
|
|
import { ENR } from "../enr";
|
2022-01-13 11:33:26 +11:00
|
|
|
|
|
2022-02-04 14:12:00 +11:00
|
|
|
|
import { DnsOverHttps, Endpoints } from "./dns_over_https";
|
|
|
|
|
|
import { ENRTree } from "./enrtree";
|
2022-05-04 20:07:21 +10:00
|
|
|
|
import fetchNodesUntilCapabilitiesFulfilled from "./fetch_nodes";
|
2022-01-13 11:33:26 +11:00
|
|
|
|
|
2022-02-04 14:12:00 +11:00
|
|
|
|
const dbg = debug("waku:discovery:dns");
|
2022-01-13 11:33:26 +11:00
|
|
|
|
|
2022-01-13 16:09:01 +11:00
|
|
|
|
export type SearchContext = {
|
2022-01-13 11:33:26 +11:00
|
|
|
|
domain: string;
|
|
|
|
|
|
publicKey: string;
|
|
|
|
|
|
visits: { [key: string]: boolean };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export interface DnsClient {
|
|
|
|
|
|
resolveTXT: (domain: string) => Promise<string[]>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2022-05-04 20:07:21 +10:00
|
|
|
|
export interface NodeCapabilityCount {
|
|
|
|
|
|
relay: number;
|
|
|
|
|
|
store: number;
|
|
|
|
|
|
filter: number;
|
|
|
|
|
|
lightPush: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2022-01-13 11:33:26 +11:00
|
|
|
|
export class DnsNodeDiscovery {
|
|
|
|
|
|
private readonly dns: DnsClient;
|
|
|
|
|
|
private readonly _DNSTreeCache: { [key: string]: string };
|
|
|
|
|
|
private readonly _errorTolerance: number = 10;
|
|
|
|
|
|
|
|
|
|
|
|
public static dnsOverHttp(endpoints?: Endpoints): DnsNodeDiscovery {
|
|
|
|
|
|
const dnsClient = new DnsOverHttps(endpoints);
|
|
|
|
|
|
return new DnsNodeDiscovery(dnsClient);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Returns a list of verified peers listed in an EIP-1459 DNS tree. Method may
|
2022-05-04 20:07:21 +10:00
|
|
|
|
* return fewer peers than requested if [[wantedNodeCapabilityCount]] requires
|
|
|
|
|
|
* larger quantity of peers than available or the number of errors/duplicate
|
|
|
|
|
|
* peers encountered by randomized search exceeds the sum of the fields of
|
|
|
|
|
|
* [[wantedNodeCapabilityCount]] plus the [[_errorTolerance]] factor.
|
2022-01-13 11:33:26 +11:00
|
|
|
|
*/
|
2022-05-04 20:07:21 +10:00
|
|
|
|
async getPeers(
|
|
|
|
|
|
enrTreeUrls: string[],
|
|
|
|
|
|
wantedNodeCapabilityCount: Partial<NodeCapabilityCount>
|
|
|
|
|
|
): Promise<ENR[]> {
|
2022-01-13 11:33:26 +11:00
|
|
|
|
const networkIndex = Math.floor(Math.random() * enrTreeUrls.length);
|
|
|
|
|
|
const { publicKey, domain } = ENRTree.parseTree(enrTreeUrls[networkIndex]);
|
2022-05-04 20:07:21 +10:00
|
|
|
|
const context: SearchContext = {
|
|
|
|
|
|
domain,
|
|
|
|
|
|
publicKey,
|
|
|
|
|
|
visits: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const peers = await fetchNodesUntilCapabilitiesFulfilled(
|
|
|
|
|
|
wantedNodeCapabilityCount,
|
|
|
|
|
|
this._errorTolerance,
|
|
|
|
|
|
() => this._search(domain, context)
|
|
|
|
|
|
);
|
|
|
|
|
|
dbg("retrieved peers: ", peers);
|
2022-01-13 11:33:26 +11:00
|
|
|
|
return peers;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public constructor(dns: DnsClient) {
|
|
|
|
|
|
this._DNSTreeCache = {};
|
|
|
|
|
|
this.dns = dns;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 {
|
2022-03-01 16:51:21 +11:00
|
|
|
|
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 ENR.decodeTxt(entry);
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
dbg(
|
|
|
|
|
|
`Failed to search DNS tree ${entryType} at subdomain ${subdomain}: ${error}`
|
|
|
|
|
|
);
|
|
|
|
|
|
return null;
|
2022-01-13 11:33:26 +11:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2022-03-01 16:51:21 +11:00
|
|
|
|
dbg(`Failed to retrieve TXT record at subdomain ${subdomain}: ${error}`);
|
2022-01-13 11:33:26 +11:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Retrieves the TXT record stored at a location from either
|
2022-03-01 16:51:21 +11:00
|
|
|
|
* this DNS tree cache or via DNS query.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @throws if the TXT Record contains non-UTF-8 values.
|
2022-01-13 11:33:26 +11:00
|
|
|
|
*/
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
assert(
|
|
|
|
|
|
response.length,
|
2022-02-04 14:12:00 +11:00
|
|
|
|
"Received empty result array while fetching TXT record"
|
2022-01-13 11:33:26 +11:00
|
|
|
|
);
|
2022-02-04 14:12:00 +11:00
|
|
|
|
assert(response[0].length, "Received empty TXT record");
|
2022-01-13 11:33:26 +11:00
|
|
|
|
|
|
|
|
|
|
// Branch entries can be an array of strings of comma delimited subdomains, with
|
|
|
|
|
|
// some subdomain strings split across the array elements
|
2022-02-04 14:12:00 +11:00
|
|
|
|
const result = response.join("");
|
2022-01-13 11:33:26 +11:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2022-02-04 14:12:00 +11:00
|
|
|
|
return "";
|
2022-01-13 11:33:26 +11:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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) {
|
2022-02-04 14:12:00 +11:00
|
|
|
|
throw new Error("Unresolvable circular path detected");
|
2022-01-13 11:33:26 +11:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Randomly select a viable path
|
|
|
|
|
|
let index;
|
|
|
|
|
|
do {
|
|
|
|
|
|
index = Math.floor(Math.random() * branches.length);
|
|
|
|
|
|
} while (circularRefs[index]);
|
|
|
|
|
|
|
|
|
|
|
|
return branches[index];
|
|
|
|
|
|
}
|