-
-
Notifications
You must be signed in to change notification settings - Fork 3
/
resolve.js
335 lines (315 loc) · 10.1 KB
/
resolve.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
const { wrapTimeout } = require('@consento/promise/wrapTimeout')
const { AbortError } = require('@consento/promise/AbortError')
const { bubbleAbort } = require('@consento/promise/bubbleAbort')
const protocols = require('./protocols.js')
const createCacheLRU = require('./cache-lru.js')
const createResolveContext = require('./resolve-context.js')
const { LightURL, urlRegex } = require('./light-url.js')
const { matchRegex, isLocal } = createResolveContext
const debug = require('debug')('hyper-dns')
const CORS_WARNING = (name, url) => `Warning, the well-known lookup for "${name}" at ${url} does not serve with the http-header access-control-allow-origin=*. This means that while this domain works in the current environment it is not universally accessible and does not conform to the standard. Please contact the host and ask them to add the http-header, thanks!`
class RecordNotFoundError extends Error {
constructor (name, msg = 'No record found for ') {
super(`${msg}${name}`)
this.name = name
}
}
RecordNotFoundError.prototype.code = 'ENOTFOUND'
function isEntryActive (protocol, name, entry, ignoreCachedMiss) {
if (entry === undefined) {
return false
}
const now = Date.now()
if (entry.expires < now) {
debug('Cached entry for %s:%s has expired: %s < %s', protocol.name, name, entry.expires, now)
return false
}
if (entry.key === null && ignoreCachedMiss) {
debug('Ignoring cached miss for %s:%s because of user option.', protocol.name, name)
return false
}
return true
}
function sanitizeTTL (ttl, minTTL, maxTTL) {
if (ttl === null || ttl === undefined) {
return
}
if (ttl < minTTL) {
debug('ttl=%s is less than minTTL=%s, using minTTL', ttl, minTTL)
return minTTL
}
if (ttl > maxTTL) {
debug('ttl=%s is more than maxTTL=%s, using maxTTL', ttl, maxTTL)
return maxTTL
}
return ttl
}
async function storeCacheEntry (opts, protocol, name, entry) {
const { cache, maxTTL } = opts
if (!cache || entry.expires === null) {
return
}
const now = Date.now()
if (now >= entry.expires) {
return
}
const maxExpires = now + maxTTL * 1000
if (entry.expires > maxExpires) {
return
}
try {
await cache.set(protocol.name, name, entry)
} catch (error) {
debug('Error while storing protocol %s and name %s in cache: %s', protocol.name, name, error)
}
}
async function fallbackToCache (opts, protocol, name, cachedEntry, error) {
const { ignoreCache, cache } = opts
if (ignoreCache && cache) {
debug('Falling back to cache, as error occured while looking up %s:%s: %s', protocol.name, name, error)
cachedEntry = await getCacheEntry(opts, protocol, name)
return cachedEntry ? cachedEntry.key : null
}
if (cachedEntry !== undefined) {
debug('Using cached entry(expires=%s) because looking up %s:%s failed: %s', cachedEntry.expires, protocol.name, name, error)
return cachedEntry.key
}
debug('Error while looking up %s:%s: %s', protocol.name, name, error)
return null
}
async function resolveRaw (opts, protocol, name) {
const { minTTL, maxTTL, signal } = opts
let { ttl } = opts
let key = null
const result = await protocol(opts.context, name)
bubbleAbort(signal)
if (result !== undefined) {
key = result.key
ttl = result.ttl
}
ttl = sanitizeTTL(ttl, minTTL, maxTTL)
if (key === null) {
debug('Lookup of %s:%s[ttl=%s] returned "null", marking it as a miss.', protocol.name, name, ttl)
} else {
debug('Successful lookup of %s:%s[ttl=%s]: %s', protocol.name, name, ttl, result.key)
}
return {
key,
expires: ttl === undefined ? null : Date.now() + ttl * 1000
}
}
async function resolveProtocol (createLookupContext, protocol, name, opts = {}) {
opts = {
...resolveProtocol.DEFAULTS,
...opts
}
protocol = getProtocol(opts, protocol)
return wrapContext(async opts => {
let cachedEntry
const { cache, ignoreCache, ignoreCachedMiss } = opts
if (!ignoreCache && cache) {
cachedEntry = await getCacheEntry(opts, protocol, name)
if (isEntryActive(protocol, name, cachedEntry, ignoreCachedMiss)) {
return cachedEntry.key
}
}
let entry
try {
entry = await resolveRaw(opts, protocol, name)
} catch (error) {
if (error instanceof AbortError || error instanceof TypeError) {
throw error
}
return await fallbackToCache(opts, protocol, name, cachedEntry, error)
}
await storeCacheEntry(opts, protocol, name, entry)
return entry.key
}, createLookupContext, opts)
}
resolveProtocol.DEFAULTS = Object.freeze({
dohLookups: Object.freeze([
'https://cloudflare-dns.com:443/dns-query',
'https://dns.google:443/resolve'
]),
userAgent: null,
cache: null,
protocols: Object.freeze(Object.values(protocols)),
ignoreCache: false,
ignoreCachedMiss: false,
ttl: 60 * 60, // 1hr
minTTL: 30, // 1/2min
maxTTL: 60 * 60 * 24 * 7, // 1 week
corsWarning: (name, url) => console.log(`${CORS_WARNING(name, url)} If you wish to hide this error, set opts.corsWarning to null.`)
})
Object.freeze(resolveProtocol)
async function resolve (createLookupContext, name, opts = {}) {
opts = {
...resolve.DEFAULTS,
...opts
}
return await wrapContext(async opts => {
const { protocols } = opts
const keys = {}
await Promise.all(protocols.map(async protocol => {
protocol = getProtocol(opts, protocol)
keys[protocol.name] = await resolveProtocol(createLookupContext, protocol, name, opts)
}))
return keys
}, createLookupContext, opts)
}
resolve.DEFAULTS = resolveProtocol.DEFAULTS
Object.freeze(resolve)
async function resolveURL (createLookupContext, input, opts) {
const url = urlRegex.exec(input).groups
if (!url.hostname) {
throw new TypeError('URL needs to specify a hostname, just a path can not resolve to anything.')
}
opts = {
...resolveURL.DEFAULTS,
localPort: url.port,
...opts
}
return await wrapContext(async opts => {
const p = url.protocol ? url.protocol.substr(0, url.protocol.length - 1) : null
if (!p || supportsProtocol(opts.protocols, p)) {
if (p) {
const key = await resolveProtocol(createLookupContext, p, url.hostname, opts)
if (key !== null) {
url.hostname = key
} else {
throw new RecordNotFoundError(url.hostname)
}
} else {
for (const protocol of getProtocols(opts)) {
const key = await resolveProtocol(createLookupContext, protocol, url.hostname, opts)
if (key !== null) {
url.protocol = `${protocol.name}:`
url.hostname = key
url.slashes = '//'
break
}
}
if (!url.protocol) {
url.protocol = `${opts.fallbackProtocol}:`
}
}
}
return new LightURL(url)
}, createLookupContext, opts)
}
resolveURL.DEFAULTS = Object.freeze({
...resolve.DEFAULTS,
protocolPreference: null,
fallbackProtocol: 'https'
})
Object.freeze(resolveURL)
module.exports = Object.freeze({
resolveProtocol,
resolve,
resolveURL,
createCacheLRU,
createResolveContext,
protocols,
RecordNotFoundError,
LightURL
})
function supportsProtocol (protocols, protocolName) {
for (const protocol of protocols) {
if (protocol.name === protocolName) {
return true
}
}
return false
}
async function wrapContext (handler, createLookupContext, opts) {
return await wrapTimeout(async signal => {
if (signal) {
opts.signal = signal
}
if (!opts.context) {
opts.context = createLookupContext(opts)
}
return await handler(opts)
}, opts)
}
function * getProtocols (opts) {
const rest = new Set(opts.protocols)
for (const protocol of getPreferredProtocols(opts)) {
yield protocol
rest.delete(protocol)
}
for (const protocol of rest) {
yield protocol
}
}
function getPreferredProtocols (opts) {
const { protocolPreference: preferences } = opts
if (preferences === null || preferences === undefined) {
return []
}
return preferences.map(preference => getProtocol(opts, preference))
}
const VALID_PROTOCOL = /^[^:]+$/
function getProtocol (opts, input) {
const protocol = typeof input === 'function'
? input
: opts.protocols.find(protocol => protocol.name === input)
if (protocol === undefined) {
throw new TypeError(`Unsupported protocol ${input}, supported protocols are [${opts.protocols.map(protocol => protocol.name).join(', ')}]`)
}
/* c8 ignore start */
if (!VALID_PROTOCOL.test(protocol.name)) {
// Note: depending on JavaScript VM, this is a possible edge case!
throw new TypeError(`Protocol name "${protocol.name}" is invalid, it needs to match ${VALID_PROTOCOL}`)
}
/* c8 ignore end */
return protocol
}
const sanitizingContext = Object.freeze({
isLocal,
matchRegex,
async getDNSTxtRecord () {},
async fetchWellKnown () {}
})
async function getCacheEntry (opts, protocol, name) {
const { cache, signal } = opts
let entry
try {
entry = await cache.get(protocol.name, name, signal)
bubbleAbort(signal)
} catch (error) {
if (error instanceof AbortError || error instanceof TypeError) {
throw error
}
debug('Error while restoring %s:%s from cache: %s', protocol.name, name, error)
}
if (entry === undefined) {
return
}
return await validateCacheEntry(protocol, name, entry)
}
async function validateCacheEntry (protocol, name, entry) {
if (entry === null) {
debug('cache entry for %s:%s was empty', protocol.name, name)
return
}
if (typeof entry !== 'object') {
debug('cache entry for %s:%s was of unexpected type %s: %s', protocol.name, name, typeof entry, entry)
return
}
const { key, expires } = entry
if (typeof expires !== 'number' || isNaN(expires)) {
debug('cache entry for %s:%s contained unexpected .expires property, expected number was: %s', protocol.name, name, expires)
return
}
if (key === null) {
return entry
}
// The protocol is supposed to use .matchRegex to see if the domain to resolve contains a key.
// A result indicates that the key indeed is valid
if (await protocol(sanitizingContext, key) === undefined) {
debug('cache entry for %s:%s not identified as valid key: %s', protocol.name, name, key)
return
}
return entry
}