Spaces:
Paused
Paused
| const { isIP } = require('node:net') | |
| const { lookup } = require('node:dns') | |
| const DecoratorHandler = require('../handler/decorator-handler') | |
| const { InvalidArgumentError, InformationalError } = require('../core/errors') | |
| const maxInt = Math.pow(2, 31) - 1 | |
| function hasSafeIterator (headers) { | |
| const prototype = Object.getPrototypeOf(headers) | |
| const ownIterator = Object.prototype.hasOwnProperty.call(headers, Symbol.iterator) | |
| return ownIterator || (prototype != null && prototype !== Object.prototype && typeof headers[Symbol.iterator] === 'function') | |
| } | |
| function isHostHeader (key) { | |
| return typeof key === 'string' && key.toLowerCase() === 'host' | |
| } | |
| function normalizeHeaders (headers) { | |
| if (headers == null) { | |
| return null | |
| } | |
| if (Array.isArray(headers)) { | |
| if (headers.length === 0 || !Array.isArray(headers[0])) { | |
| return headers | |
| } | |
| const normalized = [] | |
| for (const header of headers) { | |
| if (Array.isArray(header) && header.length === 2) { | |
| normalized.push(header[0], header[1]) | |
| } else { | |
| normalized.push(header) | |
| } | |
| } | |
| return normalized | |
| } | |
| if (typeof headers === 'object' && hasSafeIterator(headers)) { | |
| const normalized = [] | |
| for (const header of headers) { | |
| if (Array.isArray(header) && header.length === 2) { | |
| normalized.push(header[0], header[1]) | |
| } else { | |
| normalized.push(header) | |
| } | |
| } | |
| return normalized | |
| } | |
| return headers | |
| } | |
| function hasHostHeader (headers) { | |
| if (headers == null) { | |
| return false | |
| } | |
| if (Array.isArray(headers)) { | |
| if (headers.length === 0) { | |
| return false | |
| } | |
| for (let i = 0; i < headers.length; i += 2) { | |
| if (isHostHeader(headers[i])) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| if (typeof headers === 'object') { | |
| for (const key in headers) { | |
| if (isHostHeader(key)) { | |
| return true | |
| } | |
| } | |
| } | |
| return false | |
| } | |
| function withHostHeader (host, headers) { | |
| const normalizedHeaders = normalizeHeaders(headers) | |
| if (hasHostHeader(normalizedHeaders)) { | |
| return normalizedHeaders | |
| } | |
| if (Array.isArray(normalizedHeaders)) { | |
| return ['host', host, ...normalizedHeaders] | |
| } | |
| if (normalizedHeaders && typeof normalizedHeaders === 'object') { | |
| return { | |
| host, | |
| ...normalizedHeaders | |
| } | |
| } | |
| return { host } | |
| } | |
| class DNSStorage { | |
| #maxItems = 0 | |
| #records = new Map() | |
| constructor (opts) { | |
| this.#maxItems = opts.maxItems | |
| } | |
| get size () { | |
| return this.#records.size | |
| } | |
| get (hostname) { | |
| return this.#records.get(hostname) ?? null | |
| } | |
| set (hostname, records) { | |
| this.#records.set(hostname, records) | |
| } | |
| delete (hostname) { | |
| this.#records.delete(hostname) | |
| } | |
| // Delegate to storage decide can we do more lookups or not | |
| full () { | |
| return this.size >= this.#maxItems | |
| } | |
| } | |
| class DNSInstance { | |
| #maxTTL = 0 | |
| #maxItems = 0 | |
| dualStack = true | |
| affinity = null | |
| lookup = null | |
| pick = null | |
| storage = null | |
| constructor (opts) { | |
| this.#maxTTL = opts.maxTTL | |
| this.#maxItems = opts.maxItems | |
| this.dualStack = opts.dualStack | |
| this.affinity = opts.affinity | |
| this.lookup = opts.lookup ?? this.#defaultLookup | |
| this.pick = opts.pick ?? this.#defaultPick | |
| this.storage = opts.storage ?? new DNSStorage(opts) | |
| } | |
| runLookup (origin, opts, cb) { | |
| const ips = this.storage.get(origin.hostname) | |
| // If full, we just return the origin | |
| if (ips == null && this.storage.full()) { | |
| cb(null, origin) | |
| return | |
| } | |
| const newOpts = { | |
| affinity: this.affinity, | |
| dualStack: this.dualStack, | |
| lookup: this.lookup, | |
| pick: this.pick, | |
| ...opts.dns, | |
| maxTTL: this.#maxTTL, | |
| maxItems: this.#maxItems | |
| } | |
| // If no IPs we lookup | |
| if (ips == null) { | |
| this.lookup(origin, newOpts, (err, addresses) => { | |
| if (err || addresses == null || addresses.length === 0) { | |
| cb(err ?? new InformationalError('No DNS entries found')) | |
| return | |
| } | |
| this.setRecords(origin, addresses) | |
| const records = this.storage.get(origin.hostname) | |
| const ip = this.pick( | |
| origin, | |
| records, | |
| newOpts.affinity | |
| ) | |
| let port | |
| if (typeof ip.port === 'number') { | |
| port = `:${ip.port}` | |
| } else if (origin.port !== '') { | |
| port = `:${origin.port}` | |
| } else { | |
| port = '' | |
| } | |
| cb( | |
| null, | |
| new URL(`${origin.protocol}//${ | |
| ip.family === 6 ? `[${ip.address}]` : ip.address | |
| }${port}`) | |
| ) | |
| }) | |
| } else { | |
| // If there's IPs we pick | |
| const ip = this.pick( | |
| origin, | |
| ips, | |
| newOpts.affinity | |
| ) | |
| // If no IPs we lookup - deleting old records | |
| if (ip == null) { | |
| this.storage.delete(origin.hostname) | |
| this.runLookup(origin, opts, cb) | |
| return | |
| } | |
| let port | |
| if (typeof ip.port === 'number') { | |
| port = `:${ip.port}` | |
| } else if (origin.port !== '') { | |
| port = `:${origin.port}` | |
| } else { | |
| port = '' | |
| } | |
| cb( | |
| null, | |
| new URL(`${origin.protocol}//${ | |
| ip.family === 6 ? `[${ip.address}]` : ip.address | |
| }${port}`) | |
| ) | |
| } | |
| } | |
| #defaultLookup (origin, opts, cb) { | |
| lookup( | |
| origin.hostname, | |
| { | |
| all: true, | |
| family: this.dualStack === false ? this.affinity : 0, | |
| order: 'ipv4first' | |
| }, | |
| (err, addresses) => { | |
| if (err) { | |
| return cb(err) | |
| } | |
| const results = new Map() | |
| for (const addr of addresses) { | |
| // On linux we found duplicates, we attempt to remove them with | |
| // the latest record | |
| results.set(`${addr.address}:${addr.family}`, addr) | |
| } | |
| cb(null, results.values()) | |
| } | |
| ) | |
| } | |
| #defaultPick (origin, hostnameRecords, affinity) { | |
| let ip = null | |
| const { records, offset } = hostnameRecords | |
| let family | |
| if (this.dualStack) { | |
| if (affinity == null) { | |
| // Balance between ip families | |
| if (offset == null || offset === maxInt) { | |
| hostnameRecords.offset = 0 | |
| affinity = 4 | |
| } else { | |
| hostnameRecords.offset++ | |
| affinity = (hostnameRecords.offset & 1) === 1 ? 6 : 4 | |
| } | |
| } | |
| if (records[affinity] != null && records[affinity].ips.length > 0) { | |
| family = records[affinity] | |
| } else { | |
| family = records[affinity === 4 ? 6 : 4] | |
| } | |
| } else { | |
| family = records[affinity] | |
| } | |
| // If no IPs we return null | |
| if (family == null || family.ips.length === 0) { | |
| return ip | |
| } | |
| if (family.offset == null || family.offset === maxInt) { | |
| family.offset = 0 | |
| } else { | |
| family.offset++ | |
| } | |
| const position = family.offset % family.ips.length | |
| ip = family.ips[position] ?? null | |
| if (ip == null) { | |
| return ip | |
| } | |
| if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms | |
| // We delete expired records | |
| // It is possible that they have different TTL, so we manage them individually | |
| family.ips.splice(position, 1) | |
| return this.pick(origin, hostnameRecords, affinity) | |
| } | |
| return ip | |
| } | |
| pickFamily (origin, ipFamily) { | |
| const records = this.storage.get(origin.hostname)?.records | |
| if (!records) { | |
| return null | |
| } | |
| const family = records[ipFamily] | |
| if (!family) { | |
| return null | |
| } | |
| if (family.offset == null || family.offset === maxInt) { | |
| family.offset = 0 | |
| } else { | |
| family.offset++ | |
| } | |
| const position = family.offset % family.ips.length | |
| const ip = family.ips[position] ?? null | |
| if (ip == null) { | |
| return ip | |
| } | |
| if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms | |
| // We delete expired records | |
| // It is possible that they have different TTL, so we manage them individually | |
| family.ips.splice(position, 1) | |
| } | |
| return ip | |
| } | |
| setRecords (origin, addresses) { | |
| const timestamp = Date.now() | |
| const records = { records: { 4: null, 6: null } } | |
| let minTTL = this.#maxTTL | |
| for (const record of addresses) { | |
| record.timestamp = timestamp | |
| if (typeof record.ttl === 'number') { | |
| // The record TTL is expected to be in ms | |
| record.ttl = Math.min(record.ttl, this.#maxTTL) | |
| minTTL = Math.min(minTTL, record.ttl) | |
| } else { | |
| record.ttl = this.#maxTTL | |
| } | |
| const familyRecords = records.records[record.family] ?? { ips: [] } | |
| familyRecords.ips.push(record) | |
| records.records[record.family] = familyRecords | |
| } | |
| // We provide a default TTL if external storage will be used without TTL per record-level support | |
| this.storage.set(origin.hostname, records, { ttl: minTTL }) | |
| } | |
| deleteRecords (origin) { | |
| this.storage.delete(origin.hostname) | |
| } | |
| getHandler (meta, opts) { | |
| return new DNSDispatchHandler(this, meta, opts) | |
| } | |
| } | |
| class DNSDispatchHandler extends DecoratorHandler { | |
| #state = null | |
| #opts = null | |
| #dispatch = null | |
| #origin = null | |
| #controller = null | |
| #newOrigin = null | |
| #firstTry = true | |
| constructor (state, { origin, handler, dispatch, newOrigin }, opts) { | |
| super(handler) | |
| this.#origin = origin | |
| this.#newOrigin = newOrigin | |
| this.#opts = { ...opts } | |
| this.#state = state | |
| this.#dispatch = dispatch | |
| } | |
| onResponseError (controller, err) { | |
| switch (err.code) { | |
| case 'ETIMEDOUT': | |
| case 'ECONNREFUSED': { | |
| if (this.#state.dualStack) { | |
| if (!this.#firstTry) { | |
| super.onResponseError(controller, err) | |
| return | |
| } | |
| this.#firstTry = false | |
| // Pick an ip address from the other family | |
| const otherFamily = this.#newOrigin.hostname[0] === '[' ? 4 : 6 | |
| const ip = this.#state.pickFamily(this.#origin, otherFamily) | |
| if (ip == null) { | |
| super.onResponseError(controller, err) | |
| return | |
| } | |
| let port | |
| if (typeof ip.port === 'number') { | |
| port = `:${ip.port}` | |
| } else if (this.#origin.port !== '') { | |
| port = `:${this.#origin.port}` | |
| } else { | |
| port = '' | |
| } | |
| const dispatchOpts = { | |
| ...this.#opts, | |
| origin: `${this.#origin.protocol}//${ | |
| ip.family === 6 ? `[${ip.address}]` : ip.address | |
| }${port}`, | |
| headers: withHostHeader(this.#origin.host, this.#opts.headers) | |
| } | |
| this.#dispatch(dispatchOpts, this) | |
| return | |
| } | |
| // if dual-stack disabled, we error out | |
| super.onResponseError(controller, err) | |
| break | |
| } | |
| case 'ENOTFOUND': | |
| this.#state.deleteRecords(this.#origin) | |
| super.onResponseError(controller, err) | |
| break | |
| default: | |
| super.onResponseError(controller, err) | |
| break | |
| } | |
| } | |
| } | |
| module.exports = interceptorOpts => { | |
| if ( | |
| interceptorOpts?.maxTTL != null && | |
| (typeof interceptorOpts?.maxTTL !== 'number' || interceptorOpts?.maxTTL < 0) | |
| ) { | |
| throw new InvalidArgumentError('Invalid maxTTL. Must be a positive number') | |
| } | |
| if ( | |
| interceptorOpts?.maxItems != null && | |
| (typeof interceptorOpts?.maxItems !== 'number' || | |
| interceptorOpts?.maxItems < 1) | |
| ) { | |
| throw new InvalidArgumentError( | |
| 'Invalid maxItems. Must be a positive number and greater than zero' | |
| ) | |
| } | |
| if ( | |
| interceptorOpts?.affinity != null && | |
| interceptorOpts?.affinity !== 4 && | |
| interceptorOpts?.affinity !== 6 | |
| ) { | |
| throw new InvalidArgumentError('Invalid affinity. Must be either 4 or 6') | |
| } | |
| if ( | |
| interceptorOpts?.dualStack != null && | |
| typeof interceptorOpts?.dualStack !== 'boolean' | |
| ) { | |
| throw new InvalidArgumentError('Invalid dualStack. Must be a boolean') | |
| } | |
| if ( | |
| interceptorOpts?.lookup != null && | |
| typeof interceptorOpts?.lookup !== 'function' | |
| ) { | |
| throw new InvalidArgumentError('Invalid lookup. Must be a function') | |
| } | |
| if ( | |
| interceptorOpts?.pick != null && | |
| typeof interceptorOpts?.pick !== 'function' | |
| ) { | |
| throw new InvalidArgumentError('Invalid pick. Must be a function') | |
| } | |
| if ( | |
| interceptorOpts?.storage != null && | |
| (typeof interceptorOpts?.storage?.get !== 'function' || | |
| typeof interceptorOpts?.storage?.set !== 'function' || | |
| typeof interceptorOpts?.storage?.full !== 'function' || | |
| typeof interceptorOpts?.storage?.delete !== 'function' | |
| ) | |
| ) { | |
| throw new InvalidArgumentError('Invalid storage. Must be a object with methods: { get, set, full, delete }') | |
| } | |
| const dualStack = interceptorOpts?.dualStack ?? true | |
| let affinity | |
| if (dualStack) { | |
| affinity = interceptorOpts?.affinity ?? null | |
| } else { | |
| affinity = interceptorOpts?.affinity ?? 4 | |
| } | |
| const opts = { | |
| maxTTL: interceptorOpts?.maxTTL ?? 10e3, // Expressed in ms | |
| lookup: interceptorOpts?.lookup ?? null, | |
| pick: interceptorOpts?.pick ?? null, | |
| dualStack, | |
| affinity, | |
| maxItems: interceptorOpts?.maxItems ?? Infinity, | |
| storage: interceptorOpts?.storage | |
| } | |
| const instance = new DNSInstance(opts) | |
| return dispatch => { | |
| return function dnsInterceptor (origDispatchOpts, handler) { | |
| const origin = | |
| origDispatchOpts.origin.constructor === URL | |
| ? origDispatchOpts.origin | |
| : new URL(origDispatchOpts.origin) | |
| if (isIP(origin.hostname) !== 0) { | |
| return dispatch(origDispatchOpts, handler) | |
| } | |
| instance.runLookup(origin, origDispatchOpts, (err, newOrigin) => { | |
| if (err) { | |
| return handler.onResponseError(null, err) | |
| } | |
| const dispatchOpts = { | |
| ...origDispatchOpts, | |
| servername: origin.hostname, // For SNI on TLS | |
| origin: newOrigin.origin, | |
| headers: withHostHeader(origin.host, origDispatchOpts.headers) | |
| } | |
| dispatch( | |
| dispatchOpts, | |
| instance.getHandler( | |
| { origin, dispatch, handler, newOrigin }, | |
| origDispatchOpts | |
| ) | |
| ) | |
| }) | |
| return true | |
| } | |
| } | |
| } | |