From d941ad629a681d6fc5ebf81baad8693b4036da3e Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 12 Mar 2024 14:27:27 +0100 Subject: [PATCH] feat!: use custom DNS resolver in @helia/ipns for DNSLink Uses the `.dns` property from https://github.com/ipfs/helia/pull/465 to resolve DNS `TXT` records. This allows configuring discrete resolvers for different TLDs, unifies caching across different use of DNS (e.g. dnsaddr multiaddrs), etc. BREAKING CHANGE: requires @helia/interface@4.1.x or later, `resolveDns` has been renamed `resolveDNSLink` --- packages/interop/src/ipns-dnslink.spec.ts | 45 ++++++ packages/ipns/package.json | 5 +- packages/ipns/src/dns-resolvers/default.ts | 9 -- .../src/dns-resolvers/dns-json-over-https.ts | 90 ----------- .../ipns/src/dns-resolvers/dns-over-https.ts | 146 ----------------- packages/ipns/src/dns-resolvers/index.ts | 2 - .../src/dns-resolvers/resolver.browser.ts | 50 ------ packages/ipns/src/dns-resolvers/resolver.ts | 25 --- packages/ipns/src/dnslink.ts | 106 +++++++++++++ packages/ipns/src/index.ts | 60 +++---- packages/ipns/src/utils/dns.ts | 127 --------------- packages/ipns/src/utils/tlru.ts | 52 ------ packages/ipns/test/dns-resolvers.spec.ts | 86 ---------- packages/ipns/test/publish.spec.ts | 14 +- packages/ipns/test/resolve-dns.spec.ts | 150 +++++++++++++----- packages/ipns/test/resolve.spec.ts | 17 +- 16 files changed, 319 insertions(+), 665 deletions(-) create mode 100644 packages/interop/src/ipns-dnslink.spec.ts delete mode 100644 packages/ipns/src/dns-resolvers/default.ts delete mode 100644 packages/ipns/src/dns-resolvers/dns-json-over-https.ts delete mode 100644 packages/ipns/src/dns-resolvers/dns-over-https.ts delete mode 100644 packages/ipns/src/dns-resolvers/index.ts delete mode 100644 packages/ipns/src/dns-resolvers/resolver.browser.ts delete mode 100644 packages/ipns/src/dns-resolvers/resolver.ts create mode 100644 packages/ipns/src/dnslink.ts delete mode 100644 packages/ipns/src/utils/dns.ts delete mode 100644 packages/ipns/src/utils/tlru.ts delete mode 100644 packages/ipns/test/dns-resolvers.spec.ts diff --git a/packages/interop/src/ipns-dnslink.spec.ts b/packages/interop/src/ipns-dnslink.spec.ts new file mode 100644 index 000000000..867ec96c4 --- /dev/null +++ b/packages/interop/src/ipns-dnslink.spec.ts @@ -0,0 +1,45 @@ +/* eslint-env mocha */ + +import { ipns } from '@helia/ipns' +import { expect } from 'aegir/chai' +import { createHeliaNode } from './fixtures/create-helia.js' +import type { IPNS } from '@helia/ipns' +import type { HeliaLibp2p } from 'helia' + +const TEST_DOMAINS: string[] = [ + 'ipfs.io', + 'docs.ipfs.tech', + 'en.wikipedia-on-ipfs.org', + 'blog.libp2p.io', + 'consensuslab.world', + 'n0.computer', + 'protocol.ai', + 'research.protocol.ai', + 'probelab.io', + 'singularity.storage', + 'saturn.tech' +] + +describe('@helia/ipns - dnslink', () => { + let helia: HeliaLibp2p + let name: IPNS + + beforeEach(async () => { + helia = await createHeliaNode() + name = ipns(helia) + }) + + afterEach(async () => { + if (helia != null) { + await helia.stop() + } + }) + + TEST_DOMAINS.forEach(domain => { + it(`should resolve ${domain}`, async () => { + const result = await name.resolveDNSLink(domain) + + expect(result).to.have.property('cid') + }) + }) +}) diff --git a/packages/ipns/package.json b/packages/ipns/package.json index 0ca06e800..4e1532f6a 100644 --- a/packages/ipns/package.json +++ b/packages/ipns/package.json @@ -169,13 +169,10 @@ "@libp2p/kad-dht": "^12.0.8", "@libp2p/logger": "^4.0.7", "@libp2p/peer-id": "^4.0.7", - "dns-over-http-resolver": "^3.0.2", - "dns-packet": "^5.6.1", - "hashlru": "^2.3.0", + "@multiformats/dns": "^1.0.1", "interface-datastore": "^8.2.11", "ipns": "^9.0.0", "multiformats": "^13.1.0", - "p-queue": "^8.0.1", "progress-events": "^1.0.0", "uint8arrays": "^5.0.2" }, diff --git a/packages/ipns/src/dns-resolvers/default.ts b/packages/ipns/src/dns-resolvers/default.ts deleted file mode 100644 index 0586b72c6..000000000 --- a/packages/ipns/src/dns-resolvers/default.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { MAX_RECURSIVE_DEPTH, recursiveResolveDnslink } from '../utils/dns.js' -import resolve from './resolver.js' -import type { DNSResolver, ResolveDnsLinkOptions } from '../index.js' - -export function defaultResolver (): DNSResolver { - return async (domain: string, options: ResolveDnsLinkOptions = {}): Promise => { - return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, resolve, options) - } -} diff --git a/packages/ipns/src/dns-resolvers/dns-json-over-https.ts b/packages/ipns/src/dns-resolvers/dns-json-over-https.ts deleted file mode 100644 index ece490dfd..000000000 --- a/packages/ipns/src/dns-resolvers/dns-json-over-https.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* eslint-env browser */ - -import PQueue from 'p-queue' -import { CustomProgressEvent } from 'progress-events' -import { type DNSResponse, MAX_RECURSIVE_DEPTH, recursiveResolveDnslink, ipfsPathAndAnswer } 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 8427 '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/ - * @see https://datatracker.ietf.org/doc/html/rfc8427 - */ -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 { ipfsPath, answer } = ipfsPathAndAnswer(fqdn, json) - - cache.set(query, ipfsPath, answer.TTL ?? ttl) - - return ipfsPath - }, { - signal: options.signal - }) - - 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 deleted file mode 100644 index b373f06f6..000000000 --- a/packages/ipns/src/dns-resolvers/dns-over-https.ts +++ /dev/null @@ -1,146 +0,0 @@ -/* eslint-env browser */ - -import { Buffer } from 'buffer' -import dnsPacket, { type DecodedPacket } 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, MAX_RECURSIVE_DEPTH, recursiveResolveDnslink, ipfsPathAndAnswer } 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 { ipfsPath, answer } = ipfsPathAndAnswer(fqdn, json) - - cache.set(query, ipfsPath, answer.TTL ?? ttl) - - return ipfsPath - }, { - signal: options.signal - }) - - 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) - } -} - -function toDNSResponse (response: DecodedPacket): DNSResponse { - const txtType = 16 - - return { - Status: 0, - TC: response.flag_tc ?? false, - RD: response.flag_rd ?? false, - RA: response.flag_ra ?? false, - AD: response.flag_ad ?? false, - CD: 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' - } - } - - if (!Buffer.isBuffer(a.data[0])) { - return { - name: a.name, - type: txtType, - TTL: a.ttl ?? ttl, - data: String(a.data[0]) - } - } - - return { - name: a.name, - type: txtType, - TTL: a.ttl ?? ttl, - data: uint8ArrayToString(a.data[0]) - } - }) ?? [] - } -} diff --git a/packages/ipns/src/dns-resolvers/index.ts b/packages/ipns/src/dns-resolvers/index.ts deleted file mode 100644 index ee8c0db88..000000000 --- a/packages/ipns/src/dns-resolvers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { dnsOverHttps } from './dns-over-https.js' -export { dnsJsonOverHttps } from './dns-json-over-https.js' diff --git a/packages/ipns/src/dns-resolvers/resolver.browser.ts b/packages/ipns/src/dns-resolvers/resolver.browser.ts deleted file mode 100644 index 561672f74..000000000 --- a/packages/ipns/src/dns-resolvers/resolver.browser.ts +++ /dev/null @@ -1,50 +0,0 @@ -import Resolver from 'dns-over-http-resolver' -import PQueue from 'p-queue' -import { CustomProgressEvent } from 'progress-events' -import { resolveFn, type DNSResponse } from '../utils/dns.js' -import { TLRU } from '../utils/tlru.js' -import type { DNSResolver } from '../index.js' - -const cache = new TLRU(1000) -// 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 resolve: DNSResolver = async function resolve (domain, options = {}) { - const resolver = new Resolver({ maxCache: 0 }) - // try cache first - if (options.nocache !== true && cache.has(domain)) { - const response = cache.get(domain) - - if (response != null) { - options?.onProgress?.(new CustomProgressEvent('dnslink:cache', { detail: response })) - return response - } - } - - options.onProgress?.(new CustomProgressEvent('dnslink:query', { detail: domain })) - - // Add the query to the queue - const response = await httpQueue.add(async () => { - const dnslinkRecord = await resolveFn(resolver, domain) - - options.onProgress?.(new CustomProgressEvent('dnslink:answer', { detail: dnslinkRecord })) - cache.set(domain, dnslinkRecord, ttl) - - return dnslinkRecord - }, { - signal: options?.signal - }) - - if (response == null) { - throw new Error('No DNS response received') - } - - return response -} - -export default resolve diff --git a/packages/ipns/src/dns-resolvers/resolver.ts b/packages/ipns/src/dns-resolvers/resolver.ts deleted file mode 100644 index 9b63f8648..000000000 --- a/packages/ipns/src/dns-resolvers/resolver.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Resolver } from 'dns/promises' -import { CustomProgressEvent } from 'progress-events' -import { resolveFn, type DNSResponse } from '../utils/dns.js' -import type { DNSResolver } from '../index.js' - -const resolve: DNSResolver = async function resolve (domain, options = {}) { - const resolver = new Resolver() - const listener = (): void => { - resolver.cancel() - } - - options.signal?.addEventListener('abort', listener) - - try { - options.onProgress?.(new CustomProgressEvent('dnslink:query', { detail: domain })) - const dnslinkRecord = await resolveFn(resolver, domain) - - options.onProgress?.(new CustomProgressEvent('dnslink:answer', { detail: dnslinkRecord })) - return dnslinkRecord - } finally { - options.signal?.removeEventListener('abort', listener) - } -} - -export default resolve diff --git a/packages/ipns/src/dnslink.ts b/packages/ipns/src/dnslink.ts new file mode 100644 index 000000000..296fe0e47 --- /dev/null +++ b/packages/ipns/src/dnslink.ts @@ -0,0 +1,106 @@ +import { CodeError, type Logger } from '@libp2p/interface' +import { peerIdFromString } from '@libp2p/peer-id' +import { RecordType } from '@multiformats/dns' +import { CID } from 'multiformats/cid' +import type { ResolveDNSOptions } from './index.js' +import type { DNS } from '@multiformats/dns' + +const MAX_RECURSIVE_DEPTH = 32 + +async function recursiveResolveDnslink (domain: string, depth: number, dns: DNS, log: Logger, options: ResolveDNSOptions = {}): Promise { + if (depth === 0) { + throw new Error('recursion limit exceeded') + } + + const response = await dns.query(domain, { + ...options, + types: [ + RecordType.TXT + ] + }) + + // TODO: support multiple dnslink records + for (const answer of response.Answer) { + try { + let result = answer.data + + // strip leading and trailing " characters + if (result.startsWith('"') && result.endsWith('"')) { + result = result.substring(1, result.length - 1) + } + + if (!result.startsWith('dnslink=')) { + // invalid record? + continue + } + + result = result.replace('dnslink=', '') + // result is now a `/ipfs/` or `/ipns/` string + const [, protocol, domainOrCID, ...rest] = result.split('/') // e.g. ["", "ipfs", ""] + + if (protocol === 'ipfs') { + try { + const cid = CID.parse(domainOrCID) + + // if the result is a CID, we've reached the end of the recursion + return `/ipfs/${cid}${rest.length > 0 ? `/${rest.join('/')}` : ''}` + } catch {} + } else if (protocol === 'ipns') { + try { + const peerId = peerIdFromString(domainOrCID) + + // if the result is a PeerId, we've reached the end of the recursion + return `/ipns/${peerId}${rest.length > 0 ? `/${rest.join('/')}` : ''}` + } catch {} + + // if the result was another IPNS domain, try to follow it + return await recursiveResolveDomain(domainOrCID, depth - 1, dns, log, options) + } else { + log('unknown protocol "%s" in DNSLink record for domain: %s', protocol, domain) + continue + } + } catch (err: any) { + log.error('could not parse DNS link record for domain %s, %s', domain, answer.data, err) + } + } + + throw new CodeError(`No DNSLink records found for domain: ${domain}`, 'ERR_DNSLINK_NOT_FOUND') +} + +async function recursiveResolveDomain (domain: string, depth: number, dns: DNS, log: Logger, options: ResolveDNSOptions = {}): Promise { + if (depth === 0) { + throw new Error('recursion limit exceeded') + } + + try { + return await recursiveResolveDnslink(domain, depth, dns, log, 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 + return await recursiveResolveDnslink(domain, depth, dns, log, options) + } +} + +export async function resolveDNSLink (domain: string, dns: DNS, log: Logger, options: ResolveDNSOptions = {}): Promise { + // the DNSLink spec says records MUST be stored on the `_dnslink.` subdomain + // so start looking for records there, we will fall back to the bare domain + // if none are found + if (!domain.startsWith('_dnslink.')) { + domain = `_dnslink.${domain}` + } + + return recursiveResolveDomain(domain, options.maxRecursiveDepth ?? MAX_RECURSIVE_DEPTH, dns, log, options) +} diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 14d52f0d5..2debde604 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -235,13 +235,13 @@ import { ipnsSelector } from 'ipns/selector' import { ipnsValidator } from 'ipns/validator' import { CID } from 'multiformats/cid' import { CustomProgressEvent } from 'progress-events' -import { defaultResolver } from './dns-resolvers/default.js' +import { resolveDNSLink } from './dnslink.js' import { helia } from './routing/helia.js' import { localStore, type LocalStore } from './routing/local-store.js' import type { IPNSRouting, IPNSRoutingEvents } from './routing/index.js' -import type { DNSResponse } from './utils/dns.js' import type { Routing } from '@helia/interface' -import type { AbortOptions, PeerId } from '@libp2p/interface' +import type { AbortOptions, ComponentLogger, Logger, PeerId } from '@libp2p/interface' +import type { DNS, ResolveDnsProgressEvents, DNSResponse } from '@multiformats/dns' import type { Datastore } from 'interface-datastore' import type { IPNSRecord } from 'ipns' import type { ProgressEvent, ProgressOptions } from 'progress-events' @@ -299,35 +299,28 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions { +export interface ResolveDNSOptions 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 query the network for the IPNS record (default: false) + * Do not query the network for the IPNS record + * + * @default false */ offline?: boolean /** - * Do not use cached DNS entries (default: false) + * 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 google/cloudflare DoH + * When resolving DNSLink records that resolve to other DNSLink records, limit + * how many times we will recursively resolve them. * - * @see https://github.com/ipfs/helia-ipns/pull/55#discussion_r1270096881 + * @default 32 */ - resolvers?: DNSResolver[] + maxRecursiveDepth?: number } export interface RepublishOptions extends AbortOptions, ProgressOptions { @@ -359,7 +352,7 @@ export interface IPNS { /** * Resolve a CID from a dns-link style IPNS record */ - resolveDns(domain: string, options?: ResolveDNSOptions): Promise + resolveDNSLink(domain: string, options?: ResolveDNSOptions): Promise /** * Periodically republish all IPNS records found in the datastore @@ -372,21 +365,25 @@ export type { IPNSRouting } from './routing/index.js' export interface IPNSComponents { datastore: Datastore routing: Routing + dns: DNS + logger: ComponentLogger } class DefaultIPNS implements IPNS { private readonly routers: IPNSRouting[] private readonly localStore: LocalStore private timeout?: ReturnType - private readonly defaultResolvers: DNSResolver[] + private readonly dns: DNS + private readonly log: Logger - constructor (components: IPNSComponents, routers: IPNSRouting[] = [], resolvers: DNSResolver[] = []) { + constructor (components: IPNSComponents, routers: IPNSRouting[] = []) { this.routers = [ helia(components.routing), ...routers ] this.localStore = localStore(components.datastore) - this.defaultResolvers = resolvers.length > 0 ? resolvers : [defaultResolver()] + this.dns = components.dns + this.log = components.logger.forComponent('helia:ipns') } async publish (key: PeerId, value: CID | PeerId | string, options: PublishOptions = {}): Promise { @@ -426,12 +423,8 @@ class DefaultIPNS implements IPNS { return this.#resolve(record.value, options) } - async resolveDns (domain: string, options: ResolveDNSOptions = {}): Promise { - const resolvers = options.resolvers ?? this.defaultResolvers - - const dnslink = await Promise.any( - resolvers.map(async resolver => resolver(domain, options)) - ) + async resolveDNSLink (domain: string, options: ResolveDNSOptions = {}): Promise { + const dnslink = await resolveDNSLink(domain, this.dns, this.log, options) return this.#resolve(dnslink, options) } @@ -564,11 +557,10 @@ class DefaultIPNS implements IPNS { export interface IPNSOptions { routers?: IPNSRouting[] - resolvers?: DNSResolver[] } -export function ipns (components: IPNSComponents, { routers = [], resolvers = [] }: IPNSOptions = {}): IPNS { - return new DefaultIPNS(components, routers, resolvers) +export function ipns (components: IPNSComponents, { routers = [] }: IPNSOptions = {}): IPNS { + return new DefaultIPNS(components, routers) } export { ipnsValidator, type IPNSRoutingEvents } diff --git a/packages/ipns/src/utils/dns.ts b/packages/ipns/src/utils/dns.ts deleted file mode 100644 index 8baabdeaf..000000000 --- a/packages/ipns/src/utils/dns.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { CodeError } from '@libp2p/interface' -import { CID } from 'multiformats/cid' -import type { DNSResolver, 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 ipfsPathForAnswer = (answer: Answer): string => { - 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 ipfsPathAndAnswer = (domain: string, response: DNSResponse): { ipfsPath: string, answer: Answer } => { - const answer = findDNSLinkAnswer(domain, response) - - return { - ipfsPath: ipfsPathForAnswer(answer), - answer - } -} - -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 MAX_RECURSIVE_DEPTH = 32 - -export const recursiveResolveDnslink = async (domain: string, depth: number, resolve: DNSResolver, 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=', '') - // result is now a `/ipfs/` or `/ipns/` string - const domainOrCID = result.split('/')[2] // e.g. ["", "ipfs", ""] - - try { - CID.parse(domainOrCID) - - // if the result is a CID, or depth is 1, we've reached the end of the recursion - // if depth is 1, another recursive call will be made, but it would throw. - // we could return if depth is 1 and allow users to handle, but that may be a breaking change - return result - } catch {} - - return recursiveResolveDnslink(domainOrCID, depth - 1, resolve, options) -} - -interface DnsResolver { - resolveTxt(domain: string): Promise -} - -const DNSLINK_REGEX = /^dnslink=.+$/ -export const resolveFn = async (resolver: DnsResolver, domain: string): Promise => { - const records = await resolver.resolveTxt(domain) - const dnslinkRecords = records.flat() - .filter(record => DNSLINK_REGEX.test(record)) - - // we now have dns text entries as an array of strings - // only records passing the DNSLINK_REGEX text are included - // TODO: support multiple dnslink records - const dnslinkRecord = dnslinkRecords[0] - - if (dnslinkRecord == null) { - throw new CodeError(`No dnslink records found for domain: ${domain}`, 'ERR_DNSLINK_NOT_FOUND') - } - - return dnslinkRecord -} diff --git a/packages/ipns/src/utils/tlru.ts b/packages/ipns/src/utils/tlru.ts deleted file mode 100644 index 0556c0e65..000000000 --- a/packages/ipns/src/utils/tlru.ts +++ /dev/null @@ -1,52 +0,0 @@ -import hashlru from 'hashlru' - -/** - * Time Aware Least Recent Used Cache - * - * @see https://arxiv.org/pdf/1801.00390 - */ -export class TLRU { - private readonly lru: ReturnType - - constructor (maxSize: number) { - this.lru = hashlru(maxSize) - } - - get (key: string): T | undefined { - const value = this.lru.get(key) - - if (value != null) { - if (value.expire != null && value.expire < Date.now()) { - this.lru.remove(key) - - return undefined - } - - return value.value - } - - return undefined - } - - set (key: string, value: T, ttl: number): void { - this.lru.set(key, { value, expire: Date.now() + ttl }) - } - - has (key: string): boolean { - const value = this.get(key) - - if (value != null) { - return true - } - - return false - } - - remove (key: string): void { - this.lru.remove(key) - } - - clear (): void { - this.lru.clear() - } -} diff --git a/packages/ipns/test/dns-resolvers.spec.ts b/packages/ipns/test/dns-resolvers.spec.ts deleted file mode 100644 index 6913933b6..000000000 --- a/packages/ipns/test/dns-resolvers.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* 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') - - const makeCall = async (): Promise => { - await resolver('ipfs.io', { - onProgress: (evt) => { - if (evt.type.includes('dnslink:cache')) { - usedCache = true - } - } - }) - } - - for (let i = 0; i < 15; i++) { - // resolve again, should use the cache - // TTL can be pretty low which means this can be flaky if executed more slowly than the TTL - await makeCall() - if (usedCache) { - break - } - } - - 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() - }) - - it(`${name} should abort if signal is aborted`, async () => { - const signal = AbortSignal.timeout(1) - - await expect(resolver('ipfs.io', { nocache: true, signal })).to.eventually.be.rejected() - }) - }) -}) diff --git a/packages/ipns/test/publish.spec.ts b/packages/ipns/test/publish.spec.ts index d8e1ba337..b50ea7e48 100644 --- a/packages/ipns/test/publish.spec.ts +++ b/packages/ipns/test/publish.spec.ts @@ -1,5 +1,6 @@ /* eslint-env mocha */ +import { defaultLogger } from '@libp2p/logger' import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import { MemoryDatastore } from 'datastore-core' @@ -10,6 +11,7 @@ import { type StubbedInstance, stubInterface } from 'sinon-ts' import { ipns } from '../src/index.js' import type { IPNS, IPNSRouting } from '../src/index.js' import type { Routing } from '@helia/interface' +import type { DNS } from '@multiformats/dns' const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') @@ -17,6 +19,7 @@ describe('publish', () => { let name: IPNS let customRouting: StubbedInstance let heliaRouting: StubbedInstance + let dns: StubbedInstance beforeEach(async () => { const datastore = new MemoryDatastore() @@ -24,7 +27,16 @@ describe('publish', () => { customRouting.get.throws(new Error('Not found')) heliaRouting = stubInterface() - name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting] }) + name = ipns({ + datastore, + routing: heliaRouting, + dns, + logger: defaultLogger() + }, { + routers: [ + customRouting + ] + }) }) it('should publish an IPNS record with the default params', async function () { diff --git a/packages/ipns/test/resolve-dns.spec.ts b/packages/ipns/test/resolve-dns.spec.ts index 7adc0dbc9..93a5b74b1 100644 --- a/packages/ipns/test/resolve-dns.spec.ts +++ b/packages/ipns/test/resolve-dns.spec.ts @@ -1,68 +1,142 @@ /* eslint-env mocha */ +import { CodeError } from '@libp2p/interface' +import { defaultLogger } from '@libp2p/logger' import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { RecordType } from '@multiformats/dns' import { expect } from 'aegir/chai' import { MemoryDatastore } from 'datastore-core' import { type Datastore } from 'interface-datastore' import { CID } from 'multiformats/cid' -import { stub } from 'sinon' import { type StubbedInstance, stubInterface } from 'sinon-ts' -import { type IPNSRouting, ipns } from '../src/index.js' +import { ipns, type IPNS } from '../src/index.js' import type { Routing } from '@helia/interface' +import type { DNS, Answer, DNSResponse } from '@multiformats/dns' + +function dnsResponse (ansers: Answer[]): DNSResponse { + return { + Status: 0, + TC: true, + RD: true, + RA: true, + AD: true, + CD: true, + Question: [], + Answer: ansers + } +} describe('resolveDns', () => { - let customRouting: StubbedInstance let datastore: Datastore let heliaRouting: StubbedInstance + let dns: StubbedInstance + let name: IPNS beforeEach(async () => { datastore = new MemoryDatastore() - customRouting = stubInterface() - customRouting.get.throws(new Error('Not found')) heliaRouting = stubInterface() + dns = stubInterface() + + name = ipns({ + datastore, + routing: heliaRouting, + dns, + logger: defaultLogger() + }) }) - it('should use resolvers passed in constructor', async () => { - const stubbedResolver1 = stub().returns('dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + it('should resolve a domain', async () => { + dns.query.withArgs('foobar.baz').resolves(dnsResponse([{ + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn' + }])) - const name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting], resolvers: [stubbedResolver1] }) - const result = await name.resolveDns('foobar.baz', { nocache: true, offline: true }) - expect(stubbedResolver1.called).to.be.true() - expect(stubbedResolver1.calledWith('foobar.baz')).to.be.true() + const result = await name.resolveDNSLink('foobar.baz', { nocache: true, offline: true }) expect(result.cid.toString()).to.equal('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') }) - it('should allow overriding of resolvers passed in constructor', async () => { - const stubbedResolver1 = stub().returns('dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') - const stubbedResolver2 = stub().returns('dnslink=/ipfs/bafkreibm6jg3ux5qumhcn2b3flc3tyu6dmlb4xa7u5bf44yegnrjhc4yeq') + it('should retry and add `_dnslink.` to a domain', async () => { + dns.query.withArgs('foobar.baz').rejects(new CodeError('Not found', 'ENOTFOUND')) + dns.query.withArgs('_dnslink.foobar.baz').resolves(dnsResponse([{ + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn' + }])) - const name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting], resolvers: [stubbedResolver1] }) - const result = await name.resolveDns('foobar.baz', { nocache: true, offline: true, resolvers: [stubbedResolver2] }) - expect(stubbedResolver1.called).to.be.false() - expect(stubbedResolver2.called).to.be.true() - expect(stubbedResolver2.calledWith('foobar.baz')).to.be.true() - expect(result.cid.toString()).to.equal('bafkreibm6jg3ux5qumhcn2b3flc3tyu6dmlb4xa7u5bf44yegnrjhc4yeq') + const result = await name.resolveDNSLink('foobar.baz', { nocache: true, offline: true }) + expect(result.cid.toString()).to.equal('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + }) + + it('should handle bad records', async () => { + dns.query.withArgs('foobar.baz').resolves(dnsResponse([{ + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink' + }, { + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink=invalid' + }, { + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'bad text record' + }, { + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink=/ipfs/invalid cid' + }, { + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn' + }])) + + const result = await name.resolveDNSLink('foobar.baz', { nocache: true, offline: true }) + expect(result.cid.toString()).to.equal('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + }) + + it('should handle records wrapped in quotation marks', async () => { + dns.query.withArgs('foobar.baz').resolves(dnsResponse([{ + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: '"dnslink=/ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn"' + }])) + + const result = await name.resolveDNSLink('foobar.baz', { nocache: true, offline: true }) + expect(result.cid.toString()).to.equal('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') }) it('should support trailing slash in returned dnslink value', async () => { // see https://github.com/ipfs/helia/issues/402 - const stubbedResolver1 = stub().returns('dnslink=/ipfs/bafybeifcaqowoyito3qvsmbwbiugsu4umlxn4ehu223hvtubbfvwyuxjoe/') - - const name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting], resolvers: [stubbedResolver1] }) - const result = await name.resolveDns('foobar.baz', { nocache: true }) - expect(stubbedResolver1.called).to.be.true() - expect(stubbedResolver1.calledWith('foobar.baz')).to.be.true() + dns.query.withArgs('foobar.baz').resolves(dnsResponse([{ + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink=/ipfs/bafybeifcaqowoyito3qvsmbwbiugsu4umlxn4ehu223hvtubbfvwyuxjoe/' + }])) + + const result = await name.resolveDNSLink('foobar.baz', { nocache: true }) expect(result.cid.toString()).to.equal('bafybeifcaqowoyito3qvsmbwbiugsu4umlxn4ehu223hvtubbfvwyuxjoe', 'doesn\'t support trailing slashes') }) it('should support paths in returned dnslink value', async () => { // see https://github.com/ipfs/helia/issues/402 - const stubbedResolver1 = stub().returns('dnslink=/ipfs/bafybeifcaqowoyito3qvsmbwbiugsu4umlxn4ehu223hvtubbfvwyuxjoe/foobar/path/123') - - const name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting], resolvers: [stubbedResolver1] }) - const result = await name.resolveDns('foobar.baz', { nocache: true }) - expect(stubbedResolver1.called).to.be.true() - expect(stubbedResolver1.calledWith('foobar.baz')).to.be.true() + dns.query.withArgs('foobar.baz').resolves(dnsResponse([{ + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: 'dnslink=/ipfs/bafybeifcaqowoyito3qvsmbwbiugsu4umlxn4ehu223hvtubbfvwyuxjoe/foobar/path/123' + }])) + + const result = await name.resolveDNSLink('foobar.baz', { nocache: true }) expect(result.cid.toString()).to.equal('bafybeifcaqowoyito3qvsmbwbiugsu4umlxn4ehu223hvtubbfvwyuxjoe', 'doesn\'t support trailing slashes') expect(result.path).to.equal('foobar/path/123') }) @@ -70,19 +144,21 @@ describe('resolveDns', () => { it('should resolve recursive dnslink -> /', async () => { const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') const key = await createEd25519PeerId() - const stubbedResolver1 = stub().returns(`dnslink=/ipns/${key.toString()}/foobar/path/123`) - const name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting], resolvers: [stubbedResolver1] }) + dns.query.withArgs('foobar.baz').resolves(dnsResponse([{ + name: 'foobar.baz', + TTL: 60, + type: RecordType.TXT, + data: `dnslink=/ipns/${key}/foobar/path/123` + }])) await name.publish(key, cid) - const result = await name.resolveDns('foobar.baz', { nocache: true }) + const result = await name.resolveDNSLink('foobar.baz', { nocache: true }) if (result == null) { throw new Error('Did not resolve entry') } - expect(stubbedResolver1.called).to.be.true() - expect(stubbedResolver1.calledWith('foobar.baz')).to.be.true() expect(result.cid.toString()).to.equal(cid.toV1().toString()) expect(result.path).to.equal('foobar/path/123') }) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 001ad8efa..1e8b0fd8d 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -1,6 +1,7 @@ /* eslint-env mocha */ import { Record } from '@libp2p/kad-dht' +import { defaultLogger } from '@libp2p/logger' import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import { MemoryDatastore } from 'datastore-core' @@ -13,6 +14,7 @@ import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { ipns } from '../src/index.js' import type { IPNS, IPNSRouting } from '../src/index.js' import type { Routing } from '@helia/interface' +import type { DNS } from '@multiformats/dns' const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') @@ -21,14 +23,25 @@ describe('resolve', () => { let customRouting: StubbedInstance let datastore: Datastore let heliaRouting: StubbedInstance + let dns: StubbedInstance beforeEach(async () => { datastore = new MemoryDatastore() customRouting = stubInterface() customRouting.get.throws(new Error('Not found')) heliaRouting = stubInterface() - - name = ipns({ datastore, routing: heliaRouting }, { routers: [customRouting] }) + dns = stubInterface() + + name = ipns({ + datastore, + routing: heliaRouting, + dns, + logger: defaultLogger() + }, { + routers: [ + customRouting + ] + }) }) it('should resolve a record', async () => {