diff --git a/packages/ipns/package.json b/packages/ipns/package.json index 06ab144..c8fffe3 100644 --- a/packages/ipns/package.json +++ b/packages/ipns/package.json @@ -50,6 +50,10 @@ "./routing": { "types": "./dist/src/routing/index.d.ts", "import": "./dist/src/routing/index.js" + }, + "./dns-resolvers": { + "types": "./dist/src/dns-resolvers/index.d.ts", + "import": "./dist/src/dns-resolvers/index.js" } }, "eslintConfig": { @@ -166,6 +170,7 @@ "@libp2p/logger": "^2.0.6", "@libp2p/peer-id": "^2.0.1", "@libp2p/record": "^3.0.0", + "dns-packet": "^5.6.0", "hashlru": "^2.3.0", "interface-datastore": "^8.0.0", "ipns": "^6.0.0", @@ -183,7 +188,7 @@ "sinon-ts": "^1.0.0" }, "browser": { - "./dist/src/utils/resolve-dns-link.js": "./dist/src/utils/resolve-dns-link.browser.js" + "./dist/src/dns-resolvers/default.js": "./dist/src/dns-resolvers/default.browser.js" }, "typedoc": { "entryPoint": "./src/index.ts" diff --git a/packages/ipns/src/dns-resolvers/default.browser.ts b/packages/ipns/src/dns-resolvers/default.browser.ts new file mode 100644 index 0000000..95e27fb --- /dev/null +++ b/packages/ipns/src/dns-resolvers/default.browser.ts @@ -0,0 +1,75 @@ +/* eslint-env browser */ + +import PQueue from 'p-queue' +import { CustomProgressEvent } from 'progress-events' +import { TLRU } from '../utils/tlru.js' +import type { DNSResolver, ResolveDnsLinkOptions } from '../index.js' +import type { DNSResponse } from '../utils/dns.js' + +// Avoid sending multiple queries for the same hostname by caching results +const cache = new TLRU(1000) +// TODO: /api/v0/dns does not return TTL yet: https://github.com/ipfs/go-ipfs/issues/5884 +// However we know browsers themselves cache DNS records for at least 1 minute, +// which acts a provisional default ttl: https://stackoverflow.com/a/36917902/11518426 +const ttl = 60 * 1000 + +// browsers limit concurrent connections per host, +// we don't want to exhaust the limit (~6) +const httpQueue = new PQueue({ concurrency: 4 }) + +const ipfsPath = (response: { Path: string, Message: string }): string => { + if (response.Path != null) { + return response.Path + } + + throw new Error(response.Message) +} + +export function defaultResolver (): DNSResolver { + return async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise => { + const resolve = async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise => { + const searchParams = new URLSearchParams() + searchParams.set('arg', fqdn) + + const query = searchParams.toString() + + // try cache first + if (options.nocache !== true && cache.has(query)) { + const response = cache.get(query) + + if (response != null) { + options.onProgress?.(new CustomProgressEvent('dnslink:cache', { detail: response })) + return response + } + } + + options.onProgress?.(new CustomProgressEvent('dnslink:query', { detail: fqdn })) + + // fallback to delegated DNS resolver + const response = await httpQueue.add(async () => { + // Delegated HTTP resolver sending DNSLink queries to ipfs.io + const res = await fetch(`https://ipfs.io/api/v0/dns?${searchParams}`, { + signal: options.signal + }) + const query = new URL(res.url).search.slice(1) + const json = await res.json() + + options.onProgress?.(new CustomProgressEvent('dnslink:answer', { detail: json })) + + const response = ipfsPath(json) + + cache.set(query, response, ttl) + + return response + }) + + if (response == null) { + throw new Error('No DNS response received') + } + + return response + } + + return resolve(fqdn, options) + } +} diff --git a/packages/ipns/src/dns-resolvers/default.ts b/packages/ipns/src/dns-resolvers/default.ts new file mode 100644 index 0000000..f3d3273 --- /dev/null +++ b/packages/ipns/src/dns-resolvers/default.ts @@ -0,0 +1,39 @@ +import { Resolver } from 'node:dns/promises' +import { CodeError } from '@libp2p/interfaces/errors' +import { MAX_RECURSIVE_DEPTH, recursiveResolveDnslink } from '../utils/dns.js' +import type { DNSResolver, ResolveDnsLinkOptions } from '../index.js' +import type { AbortOptions } from '@libp2p/interfaces' + +export function defaultResolver (): DNSResolver { + return async (domain: string, options: ResolveDnsLinkOptions = {}): Promise => { + return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, resolve, options) + } +} + +async function resolve (domain: string, options: AbortOptions = {}): Promise { + const resolver = new Resolver() + const listener = (): void => { + resolver.cancel() + } + + options.signal?.addEventListener('abort', listener) + + try { + const DNSLINK_REGEX = /^dnslink=.+$/ + const records = await resolver.resolveTxt(domain) + const dnslinkRecords = records.reduce((rs, r) => rs.concat(r), []) + .filter(record => DNSLINK_REGEX.test(record)) + + const dnslinkRecord = dnslinkRecords[0] + + // we now have dns text entries as an array of strings + // only records passing the DNSLINK_REGEX text are included + if (dnslinkRecord == null) { + throw new CodeError(`No dnslink records found for domain: ${domain}`, 'ERR_DNSLINK_NOT_FOUND') + } + + return dnslinkRecord + } finally { + options.signal?.removeEventListener('abort', listener) + } +} diff --git a/packages/ipns/src/dns-resolvers/dns-json-over-https.ts b/packages/ipns/src/dns-resolvers/dns-json-over-https.ts new file mode 100644 index 0000000..3ab751c --- /dev/null +++ b/packages/ipns/src/dns-resolvers/dns-json-over-https.ts @@ -0,0 +1,88 @@ +/* eslint-env browser */ + +import PQueue from 'p-queue' +import { CustomProgressEvent } from 'progress-events' +import { type DNSResponse, findTTL, ipfsPath, MAX_RECURSIVE_DEPTH, recursiveResolveDnslink } from '../utils/dns.js' +import { TLRU } from '../utils/tlru.js' +import type { ResolveDnsLinkOptions, DNSResolver } from '../index.js' + +// Avoid sending multiple queries for the same hostname by caching results +const cache = new TLRU(1000) +// This TTL will be used if the remote service does not return one +const ttl = 60 * 1000 + +/** + * Uses the non-standard but easier to use 'application/dns-json' content-type + * to resolve DNS queries. + * + * Supports and server that uses the same schema as Google's DNS over HTTPS + * resolver. + * + * This resolver needs fewer dependencies than the regular DNS-over-HTTPS + * resolver so can result in a smaller bundle size and consequently is preferred + * for browser use. + * + * @see https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/ + * @see https://github.com/curl/curl/wiki/DNS-over-HTTPS#publicly-available-servers + * @see https://dnsprivacy.org/public_resolvers/ + */ +export function dnsJsonOverHttps (url: string): DNSResolver { + // browsers limit concurrent connections per host, + // we don't want preload calls to exhaust the limit (~6) + const httpQueue = new PQueue({ concurrency: 4 }) + + const resolve = async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise => { + const searchParams = new URLSearchParams() + searchParams.set('name', fqdn) + searchParams.set('type', 'TXT') + + const query = searchParams.toString() + + // try cache first + if (options.nocache !== true && cache.has(query)) { + const response = cache.get(query) + + if (response != null) { + options.onProgress?.(new CustomProgressEvent('dnslink:cache', { detail: response })) + return response + } + } + + options.onProgress?.(new CustomProgressEvent('dnslink:query', { detail: fqdn })) + + // query DNS-JSON over HTTPS server + const response = await httpQueue.add(async () => { + const res = await fetch(`${url}?${searchParams}`, { + headers: { + accept: 'application/dns-json' + }, + signal: options.signal + }) + + if (res.status !== 200) { + throw new Error(`Unexpected HTTP status: ${res.status} - ${res.statusText}`) + } + + const query = new URL(res.url).search.slice(1) + const json: DNSResponse = await res.json() + + options.onProgress?.(new CustomProgressEvent('dnslink:answer', { detail: json })) + + const result = ipfsPath(fqdn, json) + + cache.set(query, result, findTTL(fqdn, json) ?? ttl) + + return result + }) + + if (response == null) { + throw new Error('No DNS response received') + } + + return response + } + + return async (domain: string, options: ResolveDnsLinkOptions = {}) => { + return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, resolve, options) + } +} diff --git a/packages/ipns/src/dns-resolvers/dns-over-https.ts b/packages/ipns/src/dns-resolvers/dns-over-https.ts new file mode 100644 index 0000000..4e4f702 --- /dev/null +++ b/packages/ipns/src/dns-resolvers/dns-over-https.ts @@ -0,0 +1,141 @@ +/* eslint-env browser */ + +import { Buffer } from 'buffer' +import dnsPacket from 'dns-packet' +import { base64url } from 'multiformats/bases/base64' +import PQueue from 'p-queue' +import { CustomProgressEvent } from 'progress-events' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { type DNSResponse, findTTL, ipfsPath, MAX_RECURSIVE_DEPTH, recursiveResolveDnslink } from '../utils/dns.js' +import { TLRU } from '../utils/tlru.js' +import type { ResolveDnsLinkOptions, DNSResolver } from '../index.js' + +// Avoid sending multiple queries for the same hostname by caching results +const cache = new TLRU(1000) +// This TTL will be used if the remote service does not return one +const ttl = 60 * 1000 + +/** + * Uses the RFC 1035 'application/dns-message' content-type to resolve DNS + * queries. + * + * This resolver needs more dependencies than the non-standard + * DNS-JSON-over-HTTPS resolver so can result in a larger bundle size and + * consequently is not preferred for browser use. + * + * @see https://datatracker.ietf.org/doc/html/rfc1035 + * @see https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-wireformat/ + * @see https://github.com/curl/curl/wiki/DNS-over-HTTPS#publicly-available-servers + * @see https://dnsprivacy.org/public_resolvers/ + */ +export function dnsOverHttps (url: string): DNSResolver { + // browsers limit concurrent connections per host, + // we don't want preload calls to exhaust the limit (~6) + const httpQueue = new PQueue({ concurrency: 4 }) + + const resolve = async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise => { + const dnsQuery = dnsPacket.encode({ + type: 'query', + id: 0, + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: 'TXT', + name: fqdn + }] + }) + + const searchParams = new URLSearchParams() + searchParams.set('dns', base64url.encode(dnsQuery).substring(1)) + + const query = searchParams.toString() + + // try cache first + if (options.nocache !== true && cache.has(query)) { + const response = cache.get(query) + + if (response != null) { + options.onProgress?.(new CustomProgressEvent('dnslink:cache', { detail: response })) + return response + } + } + + options.onProgress?.(new CustomProgressEvent('dnslink:query', { detail: fqdn })) + + // query DNS over HTTPS server + const response = await httpQueue.add(async () => { + const res = await fetch(`${url}?${searchParams}`, { + headers: { + accept: 'application/dns-message' + }, + signal: options.signal + }) + + if (res.status !== 200) { + throw new Error(`Unexpected HTTP status: ${res.status} - ${res.statusText}`) + } + + const query = new URL(res.url).search.slice(1) + const buf = await res.arrayBuffer() + // map to expected response format + const json = toDNSResponse(dnsPacket.decode(Buffer.from(buf))) + + options.onProgress?.(new CustomProgressEvent('dnslink:answer', { detail: json })) + + const result = ipfsPath(fqdn, json) + + cache.set(query, result, findTTL(fqdn, json) ?? ttl) + + return json + }) + + if (response == null) { + throw new Error('No DNS response received') + } + + return ipfsPath(fqdn, response) + } + + return async (domain: string, options: ResolveDnsLinkOptions = {}) => { + return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, resolve, options) + } +} + +function toDNSResponse (response: dnsPacket.Packet): DNSResponse { + const txtType = 16 + + return { + Status: 0, + // @ts-expect-error field is missing from types + TC: Boolean(response.flag_tc) || false, + // @ts-expect-error field is missing from types + RD: Boolean(response.flag_rd) || false, + // @ts-expect-error field is missing from types + RA: Boolean(response.flag_ra) || false, + // @ts-expect-error field is missing from types + AD: Boolean(response.flag_ad) || false, + // @ts-expect-error field is missing from types + CD: Boolean(response.flag_cd) || false, + Question: response.questions?.map(q => ({ + name: q.name, + type: txtType + })) ?? [], + Answer: response.answers?.map(a => { + if (a.type !== 'TXT' || a.data.length < 1) { + return { + name: a.name, + type: txtType, + TTL: 0, + data: 'invalid' + } + } + + return { + name: a.name, + type: txtType, + TTL: a.ttl ?? ttl, + // @ts-expect-error we have already checked that a.data is not empty + data: uint8ArrayToString(a.data[0]) + } + }) ?? [] + } +} diff --git a/packages/ipns/src/dns-resolvers/index.ts b/packages/ipns/src/dns-resolvers/index.ts new file mode 100644 index 0000000..524c251 --- /dev/null +++ b/packages/ipns/src/dns-resolvers/index.ts @@ -0,0 +1,3 @@ + +export { dnsOverHttps } from './dns-over-https.js' +export { dnsJsonOverHttps } from './dns-json-over-https.js' diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 9d89b63..4ad736e 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -53,12 +53,54 @@ * @example * * ```typescript - * // resolve a CID from a TXT record in a DNS zone file, eg: - * // > dig ipfs.io TXT + * // resolve a CID from a TXT record in a DNS zone file, using the default + * // resolver for the current platform eg: + * // > dig _dnslink.ipfs.io TXT * // ;; ANSWER SECTION: - * // ipfs.io. 435 IN TXT "dnslink=/ipfs/Qmfoo" + * // _dnslink.ipfs.io. 60 IN TXT "dnslink=/ipns/website.ipfs.io" + * // > dig _dnslink.website.ipfs.io TXT + * // ;; ANSWER SECTION: + * // _dnslink.website.ipfs.io. 60 IN TXT "dnslink=/ipfs/QmWebsite" * * const cid = name.resolveDns('ipfs.io') + * + * console.info(cid) + * // QmWebsite + * ``` + * + * @example + * + * This example uses the Mozilla provided RFC 1035 DNS over HTTPS service. This + * uses binary DNS records so requires extra dependencies to process the + * response which can increase browser bundle sizes. + * + * If this is a concern, use the DNS-JSON-Over-HTTPS resolver instead. + * + * ```typescript + * // use DNS-Over-HTTPS + * import { dnsOverHttps } from '@helia/ipns/dns-resolvers' + * + * const cid = name.resolveDns('ipfs.io', { + * resolvers: [ + * dnsOverHttps('https://mozilla.cloudflare-dns.com/dns-query') + * ] + * }) + * ``` + * + * @example + * + * DNS-JSON-Over-HTTPS resolvers are non-standard but can result in a + * smaller browser bundle due to the response being plain JSON. + * + * ```typescript + * // use DNS-JSON-Over-HTTPS + * import { dnsJsonOverHttps } from '@helia/ipns/dns-resolvers' + * + * const cid = name.resolveDns('ipfs.io', { + * resolvers: [ + * dnsJsonOverHttps('https://mozilla.cloudflare-dns.com/dns-query') + * ] + * }) * ``` */ @@ -73,9 +115,10 @@ import { CID } from 'multiformats/cid' import { CustomProgressEvent } from 'progress-events' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { defaultResolver } from './dns-resolvers/default.js' import { localStore, type LocalStore } from './routing/local-store.js' -import { resolveDnslink } from './utils/resolve-dns-link.js' import type { IPNSRouting, IPNSRoutingEvents } from './routing/index.js' +import type { DNSResponse } from './utils/dns.js' import type { AbortOptions } from '@libp2p/interfaces' import type { Datastore } from 'interface-datastore' import type { IPNSEntry } from 'ipns' @@ -104,6 +147,11 @@ export type RepublishProgressEvents = ProgressEvent<'ipns:republish:success', IPNSEntry> | ProgressEvent<'ipns:republish:error', { record: IPNSEntry, err: Error }> +export type ResolveDnsLinkProgressEvents = + ProgressEvent<'dnslink:cache', string> | + ProgressEvent<'dnslink:query', string> | + ProgressEvent<'dnslink:answer', DNSResponse> + export interface PublishOptions extends AbortOptions, ProgressOptions { /** * Time duration of the record in ms (default: 24hrs) @@ -123,13 +171,30 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions { /** * Do not use cached DNS entries (default: false) */ nocache?: boolean } +export interface DNSResolver { + (domain: string, options?: ResolveDnsLinkOptions): Promise +} + +export interface ResolveDNSOptions extends AbortOptions, ProgressOptions { + /** + * Do not use cached DNS entries (default: false) + */ + nocache?: boolean + + /** + * These resolvers will be used to resolve the dnslink entries, if unspecified node will + * fall back to the `dns` module and browsers fall back to querying ipfs.io + */ + resolvers?: DNSResolver[] +} + export interface RepublishOptions extends AbortOptions, ProgressOptions { /** * The republish interval in ms (default: 23hrs) @@ -227,7 +292,13 @@ class DefaultIPNS implements IPNS { } async resolveDns (domain: string, options: ResolveDNSOptions = {}): Promise { - const dnslink = await resolveDnslink(domain, options) + const resolvers = options.resolvers ?? [ + defaultResolver() + ] + + const dnslink = await Promise.any( + resolvers.map(async resolver => resolver(domain, options)) + ) return this.#resolve(dnslink, options) } @@ -268,7 +339,7 @@ class DefaultIPNS implements IPNS { }, options.interval ?? DEFAULT_REPUBLISH_INTERVAL_MS) } - async #resolve (ipfsPath: string, options: ResolveOptions = {}): Promise { + async #resolve (ipfsPath: string, options: any = {}): Promise { const parts = ipfsPath.split('/') if (parts.length === 3) { diff --git a/packages/ipns/src/utils/dns.ts b/packages/ipns/src/utils/dns.ts new file mode 100644 index 0000000..5bc41e8 --- /dev/null +++ b/packages/ipns/src/utils/dns.ts @@ -0,0 +1,99 @@ +import { CodeError } from '@libp2p/interfaces/errors' +import * as isIPFS from 'is-ipfs' +import type { ResolveDnsLinkOptions } from '../index.js' + +export interface Question { + name: string + type: number +} + +export interface Answer { + name: string + type: number + TTL: number + data: string +} + +export interface DNSResponse { + Status: number + TC: boolean + RD: boolean + RA: boolean + AD: boolean + CD: boolean + Question: Question[] + Answer: Answer[] +} + +export const ipfsPath = (domain: string, response: DNSResponse): string => { + const answer = findDNSLinkAnswer(domain, response) + + let data = answer.data + + if (data.startsWith('"')) { + data = data.substring(1) + } + + if (data.endsWith('"')) { + data = data.substring(0, data.length - 1) + } + + return data.replace('dnslink=', '') +} + +export const findDNSLinkAnswer = (domain: string, response: DNSResponse): Answer => { + const answer = response.Answer.filter(a => a.data.includes('dnslink=/ipfs') || a.data.includes('dnslink=/ipns')).pop() + + if (answer == null) { + throw new CodeError(`No dnslink records found for domain: ${domain}`, 'ERR_DNSLINK_NOT_FOUND') + } + + return answer +} + +export const findTTL = (domain: string, response: DNSResponse): number => { + const answer = findDNSLinkAnswer(domain, response) + + return answer.TTL +} + +export const MAX_RECURSIVE_DEPTH = 32 + +export const recursiveResolveDnslink = async (domain: string, depth: number, resolve: (domain: string, options: ResolveDnsLinkOptions) => Promise, options: ResolveDnsLinkOptions = {}): Promise => { + if (depth === 0) { + throw new Error('recursion limit exceeded') + } + + let dnslinkRecord: string + + try { + dnslinkRecord = await resolve(domain, options) + } catch (err: any) { + // If the code is not ENOTFOUND or ERR_DNSLINK_NOT_FOUND or ENODATA then throw the error + if (err.code !== 'ENOTFOUND' && err.code !== 'ERR_DNSLINK_NOT_FOUND' && err.code !== 'ENODATA') { + throw err + } + + if (domain.startsWith('_dnslink.')) { + // The supplied domain contains a _dnslink component + // Check the non-_dnslink domain + domain = domain.replace('_dnslink.', '') + } else { + // Check the _dnslink subdomain + domain = `_dnslink.${domain}` + } + + // If this throws then we propagate the error + dnslinkRecord = await resolve(domain, options) + } + + const result = dnslinkRecord.replace('dnslink=', '') + const domainOrCID = result.split('/')[2] + const isIPFSCID = isIPFS.cid(domainOrCID) + + if (isIPFSCID || depth === 0) { + return result + } + + return recursiveResolveDnslink(domainOrCID, depth - 1, resolve, options) +} diff --git a/packages/ipns/src/utils/resolve-dns-link.browser.ts b/packages/ipns/src/utils/resolve-dns-link.browser.ts deleted file mode 100644 index 4035ca6..0000000 --- a/packages/ipns/src/utils/resolve-dns-link.browser.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-env browser */ - -import PQueue from 'p-queue' -import { TLRU } from './tlru.js' -import type { AbortOptions } from '@libp2p/interfaces' - -// Avoid sending multiple queries for the same hostname by caching results -const cache = new TLRU<{ Path: string, Message: string }>(1000) -// TODO: /api/v0/dns does not return TTL yet: https://github.com/ipfs/go-ipfs/issues/5884 -// However we know browsers themselves cache DNS records for at least 1 minute, -// which acts a provisional default ttl: https://stackoverflow.com/a/36917902/11518426 -const ttl = 60 * 1000 - -// browsers limit concurrent connections per host, -// we don't want preload calls to exhaust the limit (~6) -const httpQueue = new PQueue({ concurrency: 4 }) - -const ipfsPath = (response: { Path: string, Message: string }): string => { - if (response.Path != null) { - return response.Path - } - throw new Error(response.Message) -} - -export interface ResolveDnsLinkOptions extends AbortOptions { - nocache?: boolean -} - -export async function resolveDnslink (fqdn: string, opts: ResolveDnsLinkOptions = {}): Promise { // eslint-disable-line require-await - const resolve = async (fqdn: string, opts: ResolveDnsLinkOptions = {}): Promise => { - // @ts-expect-error - URLSearchParams does not take boolean options, only strings - const searchParams = new URLSearchParams(opts) - searchParams.set('arg', fqdn) - - // try cache first - const query = searchParams.toString() - if (opts.nocache !== true && cache.has(query)) { - const response = cache.get(query) - - if (response != null) { - return ipfsPath(response) - } - } - - // fallback to delegated DNS resolver - const response = await httpQueue.add(async () => { - // Delegated HTTP resolver sending DNSLink queries to ipfs.io - // TODO: replace hardcoded host with configurable DNS over HTTPS: https://github.com/ipfs/js-ipfs/issues/2212 - const res = await fetch(`https://ipfs.io/api/v0/dns?${searchParams}`) - const query = new URL(res.url).search.slice(1) - const json = await res.json() - cache.set(query, json, ttl) - - return json - }) - - return ipfsPath(response) - } - - return resolve(fqdn, opts) -} diff --git a/packages/ipns/src/utils/resolve-dns-link.ts b/packages/ipns/src/utils/resolve-dns-link.ts deleted file mode 100644 index d77cf2d..0000000 --- a/packages/ipns/src/utils/resolve-dns-link.ts +++ /dev/null @@ -1,65 +0,0 @@ -import dns from 'dns' -import { promisify } from 'util' -import * as isIPFS from 'is-ipfs' -import type { AbortOptions } from '@libp2p/interfaces' - -const MAX_RECURSIVE_DEPTH = 32 - -export async function resolveDnslink (domain: string, options: AbortOptions = {}): Promise { - return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, options) -} - -async function recursiveResolveDnslink (domain: string, depth: number, options: AbortOptions = {}): Promise { - if (depth === 0) { - throw new Error('recursion limit exceeded') - } - - let dnslinkRecord - - try { - dnslinkRecord = await resolve(domain) - } catch (err: any) { - // If the code is not ENOTFOUND or ERR_DNSLINK_NOT_FOUND or ENODATA then throw the error - if (err.code !== 'ENOTFOUND' && err.code !== 'ERR_DNSLINK_NOT_FOUND' && err.code !== 'ENODATA') { - throw err - } - - if (domain.startsWith('_dnslink.')) { - // The supplied domain contains a _dnslink component - // Check the non-_dnslink domain - dnslinkRecord = await resolve(domain.replace('_dnslink.', '')) - } else { - // Check the _dnslink subdomain - const _dnslinkDomain = `_dnslink.${domain}` - // If this throws then we propagate the error - dnslinkRecord = await resolve(_dnslinkDomain) - } - } - - const result = dnslinkRecord.replace('dnslink=', '') - const domainOrCID = result.split('/')[2] - const isIPFSCID = isIPFS.cid(domainOrCID) - - if (isIPFSCID || depth === 0) { - return result - } - - return recursiveResolveDnslink(domainOrCID, depth - 1, options) -} - -async function resolve (domain: string, options: AbortOptions = {}): Promise { - const DNSLINK_REGEX = /^dnslink=.+$/ - const records = await promisify(dns.resolveTxt)(domain) - const dnslinkRecords = records.reduce((rs, r) => rs.concat(r), []) - .filter(record => DNSLINK_REGEX.test(record)) - - const dnslinkRecord = dnslinkRecords[0] - - // we now have dns text entries as an array of strings - // only records passing the DNSLINK_REGEX text are included - if (dnslinkRecord == null) { - throw new Error(`No dnslink records found for domain: ${domain}`) - } - - return dnslinkRecord -} diff --git a/packages/ipns/test/dns-resolvers.spec.ts b/packages/ipns/test/dns-resolvers.spec.ts new file mode 100644 index 0000000..3fefc42 --- /dev/null +++ b/packages/ipns/test/dns-resolvers.spec.ts @@ -0,0 +1,70 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/chai' +import { defaultResolver } from '../src/dns-resolvers/default.js' +import { dnsJsonOverHttps } from '../src/dns-resolvers/dns-json-over-https.js' +import { dnsOverHttps } from '../src/dns-resolvers/dns-over-https.js' +import type { DNSResolver } from '../src/index.js' + +const resolvers: Record = { + 'dns-json-over-https': dnsJsonOverHttps('https://mozilla.cloudflare-dns.com/dns-query'), + 'dns-over-https': dnsOverHttps('https://mozilla.cloudflare-dns.com/dns-query'), + default: defaultResolver() +} + +describe('dns resolvers', () => { + Object.entries(resolvers).forEach(([name, resolver]) => { + it(`${name} should resolve`, async () => { + const result = await resolver('ipfs.io') + + expect(result).to.startWith('/ipfs') + }) + + it(`${name} should cache results`, async function () { + if (name === 'default' && globalThis.Buffer != null) { + // node dns uses OS-level caching + this.skip() + } + + let usedCache = false + + // resolve once + await resolver('ipfs.io') + + // resolve again, should use the cache + await resolver('ipfs.io', { + onProgress: (evt) => { + if (evt.type.includes('dnslink:cache')) { + usedCache = true + } + } + }) + + expect(usedCache).to.be.true() + }) + + it(`${name} should skip cache results`, async function () { + if (name === 'default' && globalThis.Buffer != null) { + // node dns uses OS-level caching + this.skip() + } + + let usedCache = false + + // resolve once + await resolver('ipfs.io') + + // resolve again, should skip the cache + await resolver('ipfs.io', { + nocache: true, + onProgress: (evt) => { + if (evt.type.includes('dnslink:cache')) { + usedCache = true + } + } + }) + + expect(usedCache).to.be.false() + }) + }) +})