dns.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. 'use strict'
  2. const { isIP } = require('node:net')
  3. const { lookup } = require('node:dns')
  4. const DecoratorHandler = require('../handler/decorator-handler')
  5. const { InvalidArgumentError, InformationalError } = require('../core/errors')
  6. const maxInt = Math.pow(2, 31) - 1
  7. class DNSInstance {
  8. #maxTTL = 0
  9. #maxItems = 0
  10. #records = new Map()
  11. dualStack = true
  12. affinity = null
  13. lookup = null
  14. pick = null
  15. constructor (opts) {
  16. this.#maxTTL = opts.maxTTL
  17. this.#maxItems = opts.maxItems
  18. this.dualStack = opts.dualStack
  19. this.affinity = opts.affinity
  20. this.lookup = opts.lookup ?? this.#defaultLookup
  21. this.pick = opts.pick ?? this.#defaultPick
  22. }
  23. get full () {
  24. return this.#records.size === this.#maxItems
  25. }
  26. runLookup (origin, opts, cb) {
  27. const ips = this.#records.get(origin.hostname)
  28. // If full, we just return the origin
  29. if (ips == null && this.full) {
  30. cb(null, origin.origin)
  31. return
  32. }
  33. const newOpts = {
  34. affinity: this.affinity,
  35. dualStack: this.dualStack,
  36. lookup: this.lookup,
  37. pick: this.pick,
  38. ...opts.dns,
  39. maxTTL: this.#maxTTL,
  40. maxItems: this.#maxItems
  41. }
  42. // If no IPs we lookup
  43. if (ips == null) {
  44. this.lookup(origin, newOpts, (err, addresses) => {
  45. if (err || addresses == null || addresses.length === 0) {
  46. cb(err ?? new InformationalError('No DNS entries found'))
  47. return
  48. }
  49. this.setRecords(origin, addresses)
  50. const records = this.#records.get(origin.hostname)
  51. const ip = this.pick(
  52. origin,
  53. records,
  54. newOpts.affinity
  55. )
  56. let port
  57. if (typeof ip.port === 'number') {
  58. port = `:${ip.port}`
  59. } else if (origin.port !== '') {
  60. port = `:${origin.port}`
  61. } else {
  62. port = ''
  63. }
  64. cb(
  65. null,
  66. `${origin.protocol}//${
  67. ip.family === 6 ? `[${ip.address}]` : ip.address
  68. }${port}`
  69. )
  70. })
  71. } else {
  72. // If there's IPs we pick
  73. const ip = this.pick(
  74. origin,
  75. ips,
  76. newOpts.affinity
  77. )
  78. // If no IPs we lookup - deleting old records
  79. if (ip == null) {
  80. this.#records.delete(origin.hostname)
  81. this.runLookup(origin, opts, cb)
  82. return
  83. }
  84. let port
  85. if (typeof ip.port === 'number') {
  86. port = `:${ip.port}`
  87. } else if (origin.port !== '') {
  88. port = `:${origin.port}`
  89. } else {
  90. port = ''
  91. }
  92. cb(
  93. null,
  94. `${origin.protocol}//${
  95. ip.family === 6 ? `[${ip.address}]` : ip.address
  96. }${port}`
  97. )
  98. }
  99. }
  100. #defaultLookup (origin, opts, cb) {
  101. lookup(
  102. origin.hostname,
  103. {
  104. all: true,
  105. family: this.dualStack === false ? this.affinity : 0,
  106. order: 'ipv4first'
  107. },
  108. (err, addresses) => {
  109. if (err) {
  110. return cb(err)
  111. }
  112. const results = new Map()
  113. for (const addr of addresses) {
  114. // On linux we found duplicates, we attempt to remove them with
  115. // the latest record
  116. results.set(`${addr.address}:${addr.family}`, addr)
  117. }
  118. cb(null, results.values())
  119. }
  120. )
  121. }
  122. #defaultPick (origin, hostnameRecords, affinity) {
  123. let ip = null
  124. const { records, offset } = hostnameRecords
  125. let family
  126. if (this.dualStack) {
  127. if (affinity == null) {
  128. // Balance between ip families
  129. if (offset == null || offset === maxInt) {
  130. hostnameRecords.offset = 0
  131. affinity = 4
  132. } else {
  133. hostnameRecords.offset++
  134. affinity = (hostnameRecords.offset & 1) === 1 ? 6 : 4
  135. }
  136. }
  137. if (records[affinity] != null && records[affinity].ips.length > 0) {
  138. family = records[affinity]
  139. } else {
  140. family = records[affinity === 4 ? 6 : 4]
  141. }
  142. } else {
  143. family = records[affinity]
  144. }
  145. // If no IPs we return null
  146. if (family == null || family.ips.length === 0) {
  147. return ip
  148. }
  149. if (family.offset == null || family.offset === maxInt) {
  150. family.offset = 0
  151. } else {
  152. family.offset++
  153. }
  154. const position = family.offset % family.ips.length
  155. ip = family.ips[position] ?? null
  156. if (ip == null) {
  157. return ip
  158. }
  159. if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms
  160. // We delete expired records
  161. // It is possible that they have different TTL, so we manage them individually
  162. family.ips.splice(position, 1)
  163. return this.pick(origin, hostnameRecords, affinity)
  164. }
  165. return ip
  166. }
  167. setRecords (origin, addresses) {
  168. const timestamp = Date.now()
  169. const records = { records: { 4: null, 6: null } }
  170. for (const record of addresses) {
  171. record.timestamp = timestamp
  172. if (typeof record.ttl === 'number') {
  173. // The record TTL is expected to be in ms
  174. record.ttl = Math.min(record.ttl, this.#maxTTL)
  175. } else {
  176. record.ttl = this.#maxTTL
  177. }
  178. const familyRecords = records.records[record.family] ?? { ips: [] }
  179. familyRecords.ips.push(record)
  180. records.records[record.family] = familyRecords
  181. }
  182. this.#records.set(origin.hostname, records)
  183. }
  184. getHandler (meta, opts) {
  185. return new DNSDispatchHandler(this, meta, opts)
  186. }
  187. }
  188. class DNSDispatchHandler extends DecoratorHandler {
  189. #state = null
  190. #opts = null
  191. #dispatch = null
  192. #handler = null
  193. #origin = null
  194. constructor (state, { origin, handler, dispatch }, opts) {
  195. super(handler)
  196. this.#origin = origin
  197. this.#handler = handler
  198. this.#opts = { ...opts }
  199. this.#state = state
  200. this.#dispatch = dispatch
  201. }
  202. onError (err) {
  203. switch (err.code) {
  204. case 'ETIMEDOUT':
  205. case 'ECONNREFUSED': {
  206. if (this.#state.dualStack) {
  207. // We delete the record and retry
  208. this.#state.runLookup(this.#origin, this.#opts, (err, newOrigin) => {
  209. if (err) {
  210. return this.#handler.onError(err)
  211. }
  212. const dispatchOpts = {
  213. ...this.#opts,
  214. origin: newOrigin
  215. }
  216. this.#dispatch(dispatchOpts, this)
  217. })
  218. // if dual-stack disabled, we error out
  219. return
  220. }
  221. this.#handler.onError(err)
  222. return
  223. }
  224. case 'ENOTFOUND':
  225. this.#state.deleteRecord(this.#origin)
  226. // eslint-disable-next-line no-fallthrough
  227. default:
  228. this.#handler.onError(err)
  229. break
  230. }
  231. }
  232. }
  233. module.exports = interceptorOpts => {
  234. if (
  235. interceptorOpts?.maxTTL != null &&
  236. (typeof interceptorOpts?.maxTTL !== 'number' || interceptorOpts?.maxTTL < 0)
  237. ) {
  238. throw new InvalidArgumentError('Invalid maxTTL. Must be a positive number')
  239. }
  240. if (
  241. interceptorOpts?.maxItems != null &&
  242. (typeof interceptorOpts?.maxItems !== 'number' ||
  243. interceptorOpts?.maxItems < 1)
  244. ) {
  245. throw new InvalidArgumentError(
  246. 'Invalid maxItems. Must be a positive number and greater than zero'
  247. )
  248. }
  249. if (
  250. interceptorOpts?.affinity != null &&
  251. interceptorOpts?.affinity !== 4 &&
  252. interceptorOpts?.affinity !== 6
  253. ) {
  254. throw new InvalidArgumentError('Invalid affinity. Must be either 4 or 6')
  255. }
  256. if (
  257. interceptorOpts?.dualStack != null &&
  258. typeof interceptorOpts?.dualStack !== 'boolean'
  259. ) {
  260. throw new InvalidArgumentError('Invalid dualStack. Must be a boolean')
  261. }
  262. if (
  263. interceptorOpts?.lookup != null &&
  264. typeof interceptorOpts?.lookup !== 'function'
  265. ) {
  266. throw new InvalidArgumentError('Invalid lookup. Must be a function')
  267. }
  268. if (
  269. interceptorOpts?.pick != null &&
  270. typeof interceptorOpts?.pick !== 'function'
  271. ) {
  272. throw new InvalidArgumentError('Invalid pick. Must be a function')
  273. }
  274. const dualStack = interceptorOpts?.dualStack ?? true
  275. let affinity
  276. if (dualStack) {
  277. affinity = interceptorOpts?.affinity ?? null
  278. } else {
  279. affinity = interceptorOpts?.affinity ?? 4
  280. }
  281. const opts = {
  282. maxTTL: interceptorOpts?.maxTTL ?? 10e3, // Expressed in ms
  283. lookup: interceptorOpts?.lookup ?? null,
  284. pick: interceptorOpts?.pick ?? null,
  285. dualStack,
  286. affinity,
  287. maxItems: interceptorOpts?.maxItems ?? Infinity
  288. }
  289. const instance = new DNSInstance(opts)
  290. return dispatch => {
  291. return function dnsInterceptor (origDispatchOpts, handler) {
  292. const origin =
  293. origDispatchOpts.origin.constructor === URL
  294. ? origDispatchOpts.origin
  295. : new URL(origDispatchOpts.origin)
  296. if (isIP(origin.hostname) !== 0) {
  297. return dispatch(origDispatchOpts, handler)
  298. }
  299. instance.runLookup(origin, origDispatchOpts, (err, newOrigin) => {
  300. if (err) {
  301. return handler.onError(err)
  302. }
  303. let dispatchOpts = null
  304. dispatchOpts = {
  305. ...origDispatchOpts,
  306. servername: origin.hostname, // For SNI on TLS
  307. origin: newOrigin,
  308. headers: {
  309. host: origin.hostname,
  310. ...origDispatchOpts.headers
  311. }
  312. }
  313. dispatch(
  314. dispatchOpts,
  315. instance.getHandler({ origin, dispatch, handler }, origDispatchOpts)
  316. )
  317. })
  318. return true
  319. }
  320. }
  321. }