|
| 1 | +/* eslint-env browser */ |
| 2 | + |
| 3 | +import { Buffer } from 'buffer' |
| 4 | +import dnsPacket from 'dns-packet' |
| 5 | +import { base64url } from 'multiformats/bases/base64' |
| 6 | +import PQueue from 'p-queue' |
| 7 | +import { CustomProgressEvent } from 'progress-events' |
| 8 | +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' |
| 9 | +import { type DNSResponse, findTTL, ipfsPath, MAX_RECURSIVE_DEPTH, recursiveResolveDnslink } from '../utils/dns.js' |
| 10 | +import { TLRU } from '../utils/tlru.js' |
| 11 | +import type { ResolveDnsLinkOptions, DNSResolver } from '../index.js' |
| 12 | + |
| 13 | +// Avoid sending multiple queries for the same hostname by caching results |
| 14 | +const cache = new TLRU<string>(1000) |
| 15 | +// This TTL will be used if the remote service does not return one |
| 16 | +const ttl = 60 * 1000 |
| 17 | + |
| 18 | +/** |
| 19 | + * Uses the RFC 1035 'application/dns-message' content-type to resolve DNS |
| 20 | + * queries. |
| 21 | + * |
| 22 | + * This resolver needs more dependencies than the non-standard |
| 23 | + * DNS-JSON-over-HTTPS resolver so can result in a larger bundle size and |
| 24 | + * consequently is not preferred for browser use. |
| 25 | + * |
| 26 | + * @see https://datatracker.ietf.org/doc/html/rfc1035 |
| 27 | + * @see https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-wireformat/ |
| 28 | + * @see https://github.com/curl/curl/wiki/DNS-over-HTTPS#publicly-available-servers |
| 29 | + * @see https://dnsprivacy.org/public_resolvers/ |
| 30 | + */ |
| 31 | +export function dnsOverHttps (url: string): DNSResolver { |
| 32 | + // browsers limit concurrent connections per host, |
| 33 | + // we don't want preload calls to exhaust the limit (~6) |
| 34 | + const httpQueue = new PQueue({ concurrency: 4 }) |
| 35 | + |
| 36 | + const resolve = async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise<string> => { |
| 37 | + const dnsQuery = dnsPacket.encode({ |
| 38 | + type: 'query', |
| 39 | + id: 0, |
| 40 | + flags: dnsPacket.RECURSION_DESIRED, |
| 41 | + questions: [{ |
| 42 | + type: 'TXT', |
| 43 | + name: fqdn |
| 44 | + }] |
| 45 | + }) |
| 46 | + |
| 47 | + const searchParams = new URLSearchParams() |
| 48 | + searchParams.set('dns', base64url.encode(dnsQuery).substring(1)) |
| 49 | + |
| 50 | + const query = searchParams.toString() |
| 51 | + |
| 52 | + // try cache first |
| 53 | + if (options.nocache !== true && cache.has(query)) { |
| 54 | + const response = cache.get(query) |
| 55 | + |
| 56 | + if (response != null) { |
| 57 | + options.onProgress?.(new CustomProgressEvent<string>('dnslink:cache', { detail: response })) |
| 58 | + return response |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + options.onProgress?.(new CustomProgressEvent<string>('dnslink:query', { detail: fqdn })) |
| 63 | + |
| 64 | + // query DNS over HTTPS server |
| 65 | + const response = await httpQueue.add(async () => { |
| 66 | + const res = await fetch(`${url}?${searchParams}`, { |
| 67 | + headers: { |
| 68 | + accept: 'application/dns-message' |
| 69 | + }, |
| 70 | + signal: options.signal |
| 71 | + }) |
| 72 | + |
| 73 | + if (res.status !== 200) { |
| 74 | + throw new Error(`Unexpected HTTP status: ${res.status} - ${res.statusText}`) |
| 75 | + } |
| 76 | + |
| 77 | + const query = new URL(res.url).search.slice(1) |
| 78 | + const buf = await res.arrayBuffer() |
| 79 | + // map to expected response format |
| 80 | + const json = toDNSResponse(dnsPacket.decode(Buffer.from(buf))) |
| 81 | + |
| 82 | + options.onProgress?.(new CustomProgressEvent<DNSResponse>('dnslink:answer', { detail: json })) |
| 83 | + |
| 84 | + const result = ipfsPath(fqdn, json) |
| 85 | + |
| 86 | + cache.set(query, result, findTTL(fqdn, json) ?? ttl) |
| 87 | + |
| 88 | + return json |
| 89 | + }) |
| 90 | + |
| 91 | + if (response == null) { |
| 92 | + throw new Error('No DNS response received') |
| 93 | + } |
| 94 | + |
| 95 | + return ipfsPath(fqdn, response) |
| 96 | + } |
| 97 | + |
| 98 | + return async (domain: string, options: ResolveDnsLinkOptions = {}) => { |
| 99 | + return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, resolve, options) |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +function toDNSResponse (response: dnsPacket.Packet): DNSResponse { |
| 104 | + const txtType = 16 |
| 105 | + |
| 106 | + return { |
| 107 | + Status: 0, |
| 108 | + // @ts-expect-error field is missing from types |
| 109 | + TC: Boolean(response.flag_tc) || false, |
| 110 | + // @ts-expect-error field is missing from types |
| 111 | + RD: Boolean(response.flag_rd) || false, |
| 112 | + // @ts-expect-error field is missing from types |
| 113 | + RA: Boolean(response.flag_ra) || false, |
| 114 | + // @ts-expect-error field is missing from types |
| 115 | + AD: Boolean(response.flag_ad) || false, |
| 116 | + // @ts-expect-error field is missing from types |
| 117 | + CD: Boolean(response.flag_cd) || false, |
| 118 | + Question: response.questions?.map(q => ({ |
| 119 | + name: q.name, |
| 120 | + type: txtType |
| 121 | + })) ?? [], |
| 122 | + Answer: response.answers?.map(a => { |
| 123 | + if (a.type !== 'TXT' || a.data.length < 1) { |
| 124 | + return { |
| 125 | + name: a.name, |
| 126 | + type: txtType, |
| 127 | + TTL: 0, |
| 128 | + data: 'invalid' |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + return { |
| 133 | + name: a.name, |
| 134 | + type: txtType, |
| 135 | + TTL: a.ttl ?? ttl, |
| 136 | + // @ts-expect-error we have already checked that a.data is not empty |
| 137 | + data: uint8ArrayToString(a.data[0]) |
| 138 | + } |
| 139 | + }) ?? [] |
| 140 | + } |
| 141 | +} |
0 commit comments