Skip to content
This repository was archived by the owner on Jan 8, 2024. It is now read-only.

Commit d20f584

Browse files
committed
feat: support DNS over HTTPS and DNS-JSON over HTTPS
Adds support for resoving DNSLink TXT entries from public DNS-Over-HTTPS servers (RFC 1035) and also DNS-JSON-Over-HTTPS since they are a bit kinder on the resulting browser bundle size. Fixes #53
1 parent 312381c commit d20f584

11 files changed

+599
-134
lines changed

packages/ipns/package.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
"./routing": {
5151
"types": "./dist/src/routing/index.d.ts",
5252
"import": "./dist/src/routing/index.js"
53+
},
54+
"./dns-resolvers": {
55+
"types": "./dist/src/dns-resolvers/index.d.ts",
56+
"import": "./dist/src/dns-resolvers/index.js"
5357
}
5458
},
5559
"eslintConfig": {
@@ -166,6 +170,7 @@
166170
"@libp2p/logger": "^2.0.6",
167171
"@libp2p/peer-id": "^2.0.1",
168172
"@libp2p/record": "^3.0.0",
173+
"dns-packet": "^5.6.0",
169174
"hashlru": "^2.3.0",
170175
"interface-datastore": "^8.0.0",
171176
"ipns": "^6.0.0",
@@ -183,7 +188,7 @@
183188
"sinon-ts": "^1.0.0"
184189
},
185190
"browser": {
186-
"./dist/src/utils/resolve-dns-link.js": "./dist/src/utils/resolve-dns-link.browser.js"
191+
"./dist/src/dns-resolvers/default.js": "./dist/src/dns-resolvers/default.browser.js"
187192
},
188193
"typedoc": {
189194
"entryPoint": "./src/index.ts"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* eslint-env browser */
2+
3+
import PQueue from 'p-queue'
4+
import { CustomProgressEvent } from 'progress-events'
5+
import { TLRU } from '../utils/tlru.js'
6+
import type { DNSResolver, ResolveDnsLinkOptions } from '../index.js'
7+
import type { DNSResponse } from '../utils/dns.js'
8+
9+
// Avoid sending multiple queries for the same hostname by caching results
10+
const cache = new TLRU<string>(1000)
11+
// TODO: /api/v0/dns does not return TTL yet: https://github.com/ipfs/go-ipfs/issues/5884
12+
// However we know browsers themselves cache DNS records for at least 1 minute,
13+
// which acts a provisional default ttl: https://stackoverflow.com/a/36917902/11518426
14+
const ttl = 60 * 1000
15+
16+
// browsers limit concurrent connections per host,
17+
// we don't want to exhaust the limit (~6)
18+
const httpQueue = new PQueue({ concurrency: 4 })
19+
20+
const ipfsPath = (response: { Path: string, Message: string }): string => {
21+
if (response.Path != null) {
22+
return response.Path
23+
}
24+
25+
throw new Error(response.Message)
26+
}
27+
28+
export function defaultResolver (): DNSResolver {
29+
return async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise<string> => {
30+
const resolve = async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise<string> => {
31+
const searchParams = new URLSearchParams()
32+
searchParams.set('arg', fqdn)
33+
34+
const query = searchParams.toString()
35+
36+
// try cache first
37+
if (options.nocache !== true && cache.has(query)) {
38+
const response = cache.get(query)
39+
40+
if (response != null) {
41+
options.onProgress?.(new CustomProgressEvent<string>('dnslink:cache', { detail: response }))
42+
return response
43+
}
44+
}
45+
46+
options.onProgress?.(new CustomProgressEvent<string>('dnslink:query', { detail: fqdn }))
47+
48+
// fallback to delegated DNS resolver
49+
const response = await httpQueue.add(async () => {
50+
// Delegated HTTP resolver sending DNSLink queries to ipfs.io
51+
const res = await fetch(`https://ipfs.io/api/v0/dns?${searchParams}`, {
52+
signal: options.signal
53+
})
54+
const query = new URL(res.url).search.slice(1)
55+
const json = await res.json()
56+
57+
options.onProgress?.(new CustomProgressEvent<DNSResponse>('dnslink:answer', { detail: json }))
58+
59+
const response = ipfsPath(json)
60+
61+
cache.set(query, response, ttl)
62+
63+
return response
64+
})
65+
66+
if (response == null) {
67+
throw new Error('No DNS response received')
68+
}
69+
70+
return response
71+
}
72+
73+
return resolve(fqdn, options)
74+
}
75+
}
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Resolver } from 'node:dns/promises'
2+
import { CodeError } from '@libp2p/interfaces/errors'
3+
import { MAX_RECURSIVE_DEPTH, recursiveResolveDnslink } from '../utils/dns.js'
4+
import type { DNSResolver, ResolveDnsLinkOptions } from '../index.js'
5+
import type { AbortOptions } from '@libp2p/interfaces'
6+
7+
export function defaultResolver (): DNSResolver {
8+
return async (domain: string, options: ResolveDnsLinkOptions = {}): Promise<string> => {
9+
return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, resolve, options)
10+
}
11+
}
12+
13+
async function resolve (domain: string, options: AbortOptions = {}): Promise<string> {
14+
const resolver = new Resolver()
15+
const listener = (): void => {
16+
resolver.cancel()
17+
}
18+
19+
options.signal?.addEventListener('abort', listener)
20+
21+
try {
22+
const DNSLINK_REGEX = /^dnslink=.+$/
23+
const records = await resolver.resolveTxt(domain)
24+
const dnslinkRecords = records.reduce((rs, r) => rs.concat(r), [])
25+
.filter(record => DNSLINK_REGEX.test(record))
26+
27+
const dnslinkRecord = dnslinkRecords[0]
28+
29+
// we now have dns text entries as an array of strings
30+
// only records passing the DNSLINK_REGEX text are included
31+
if (dnslinkRecord == null) {
32+
throw new CodeError(`No dnslink records found for domain: ${domain}`, 'ERR_DNSLINK_NOT_FOUND')
33+
}
34+
35+
return dnslinkRecord
36+
} finally {
37+
options.signal?.removeEventListener('abort', listener)
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/* eslint-env browser */
2+
3+
import PQueue from 'p-queue'
4+
import { CustomProgressEvent } from 'progress-events'
5+
import { type DNSResponse, findTTL, ipfsPath, MAX_RECURSIVE_DEPTH, recursiveResolveDnslink } from '../utils/dns.js'
6+
import { TLRU } from '../utils/tlru.js'
7+
import type { ResolveDnsLinkOptions, DNSResolver } from '../index.js'
8+
9+
// Avoid sending multiple queries for the same hostname by caching results
10+
const cache = new TLRU<string>(1000)
11+
// This TTL will be used if the remote service does not return one
12+
const ttl = 60 * 1000
13+
14+
/**
15+
* Uses the non-standard but easier to use 'application/dns-json' content-type
16+
* to resolve DNS queries.
17+
*
18+
* Supports and server that uses the same schema as Google's DNS over HTTPS
19+
* resolver.
20+
*
21+
* This resolver needs fewer dependencies than the regular DNS-over-HTTPS
22+
* resolver so can result in a smaller bundle size and consequently is preferred
23+
* for browser use.
24+
*
25+
* @see https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/
26+
* @see https://github.com/curl/curl/wiki/DNS-over-HTTPS#publicly-available-servers
27+
* @see https://dnsprivacy.org/public_resolvers/
28+
*/
29+
export function dnsJsonOverHttps (url: string): DNSResolver {
30+
// browsers limit concurrent connections per host,
31+
// we don't want preload calls to exhaust the limit (~6)
32+
const httpQueue = new PQueue({ concurrency: 4 })
33+
34+
const resolve = async (fqdn: string, options: ResolveDnsLinkOptions = {}): Promise<string> => {
35+
const searchParams = new URLSearchParams()
36+
searchParams.set('name', fqdn)
37+
searchParams.set('type', 'TXT')
38+
39+
const query = searchParams.toString()
40+
41+
// try cache first
42+
if (options.nocache !== true && cache.has(query)) {
43+
const response = cache.get(query)
44+
45+
if (response != null) {
46+
options.onProgress?.(new CustomProgressEvent<string>('dnslink:cache', { detail: response }))
47+
return response
48+
}
49+
}
50+
51+
options.onProgress?.(new CustomProgressEvent<string>('dnslink:query', { detail: fqdn }))
52+
53+
// query DNS-JSON over HTTPS server
54+
const response = await httpQueue.add(async () => {
55+
const res = await fetch(`${url}?${searchParams}`, {
56+
headers: {
57+
accept: 'application/dns-json'
58+
},
59+
signal: options.signal
60+
})
61+
62+
if (res.status !== 200) {
63+
throw new Error(`Unexpected HTTP status: ${res.status} - ${res.statusText}`)
64+
}
65+
66+
const query = new URL(res.url).search.slice(1)
67+
const json: DNSResponse = await res.json()
68+
69+
options.onProgress?.(new CustomProgressEvent<DNSResponse>('dnslink:answer', { detail: json }))
70+
71+
const result = ipfsPath(fqdn, json)
72+
73+
cache.set(query, result, findTTL(fqdn, json) ?? ttl)
74+
75+
return result
76+
})
77+
78+
if (response == null) {
79+
throw new Error('No DNS response received')
80+
}
81+
82+
return response
83+
}
84+
85+
return async (domain: string, options: ResolveDnsLinkOptions = {}) => {
86+
return recursiveResolveDnslink(domain, MAX_RECURSIVE_DEPTH, resolve, options)
87+
}
88+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
export { dnsOverHttps } from './dns-over-https.js'
3+
export { dnsJsonOverHttps } from './dns-json-over-https.js'

0 commit comments

Comments
 (0)