dns.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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 DNSStorage {
  8. #maxItems = 0
  9. #records = new Map()
  10. constructor (opts) {
  11. this.#maxItems = opts.maxItems
  12. }
  13. get size () {
  14. return this.#records.size
  15. }
  16. get (hostname) {
  17. return this.#records.get(hostname) ?? null
  18. }
  19. set (hostname, records) {
  20. this.#records.set(hostname, records)
  21. }
  22. delete (hostname) {
  23. this.#records.delete(hostname)
  24. }
  25. // Delegate to storage decide can we do more lookups or not
  26. full () {
  27. return this.size >= this.#maxItems
  28. }
  29. }
  30. class DNSInstance {
  31. #maxTTL = 0
  32. #maxItems = 0
  33. dualStack = true
  34. affinity = null
  35. lookup = null
  36. pick = null
  37. storage = null
  38. constructor (opts) {
  39. this.#maxTTL = opts.maxTTL
  40. this.#maxItems = opts.maxItems
  41. this.dualStack = opts.dualStack
  42. this.affinity = opts.affinity
  43. this.lookup = opts.lookup ?? this.#defaultLookup
  44. this.pick = opts.pick ?? this.#defaultPick
  45. this.storage = opts.storage ?? new DNSStorage(opts)
  46. }
  47. runLookup (origin, opts, cb) {
  48. const ips = this.storage.get(origin.hostname)
  49. // If full, we just return the origin
  50. if (ips == null && this.storage.full()) {
  51. cb(null, origin)
  52. return
  53. }
  54. const newOpts = {
  55. affinity: this.affinity,
  56. dualStack: this.dualStack,
  57. lookup: this.lookup,
  58. pick: this.pick,
  59. ...opts.dns,
  60. maxTTL: this.#maxTTL,
  61. maxItems: this.#maxItems
  62. }
  63. // If no IPs we lookup
  64. if (ips == null) {
  65. this.lookup(origin, newOpts, (err, addresses) => {
  66. if (err || addresses == null || addresses.length === 0) {
  67. cb(err ?? new InformationalError('No DNS entries found'))
  68. return
  69. }
  70. this.setRecords(origin, addresses)
  71. const records = this.storage.get(origin.hostname)
  72. const ip = this.pick(
  73. origin,
  74. records,
  75. newOpts.affinity
  76. )
  77. let port
  78. if (typeof ip.port === 'number') {
  79. port = `:${ip.port}`
  80. } else if (origin.port !== '') {
  81. port = `:${origin.port}`
  82. } else {
  83. port = ''
  84. }
  85. cb(
  86. null,
  87. new URL(`${origin.protocol}//${
  88. ip.family === 6 ? `[${ip.address}]` : ip.address
  89. }${port}`)
  90. )
  91. })
  92. } else {
  93. // If there's IPs we pick
  94. const ip = this.pick(
  95. origin,
  96. ips,
  97. newOpts.affinity
  98. )
  99. // If no IPs we lookup - deleting old records
  100. if (ip == null) {
  101. this.storage.delete(origin.hostname)
  102. this.runLookup(origin, opts, cb)
  103. return
  104. }
  105. let port
  106. if (typeof ip.port === 'number') {
  107. port = `:${ip.port}`
  108. } else if (origin.port !== '') {
  109. port = `:${origin.port}`
  110. } else {
  111. port = ''
  112. }
  113. cb(
  114. null,
  115. new URL(`${origin.protocol}//${
  116. ip.family === 6 ? `[${ip.address}]` : ip.address
  117. }${port}`)
  118. )
  119. }
  120. }
  121. #defaultLookup (origin, opts, cb) {
  122. lookup(
  123. origin.hostname,
  124. {
  125. all: true,
  126. family: this.dualStack === false ? this.affinity : 0,
  127. order: 'ipv4first'
  128. },
  129. (err, addresses) => {
  130. if (err) {
  131. return cb(err)
  132. }
  133. const results = new Map()
  134. for (const addr of addresses) {
  135. // On linux we found duplicates, we attempt to remove them with
  136. // the latest record
  137. results.set(`${addr.address}:${addr.family}`, addr)
  138. }
  139. cb(null, results.values())
  140. }
  141. )
  142. }
  143. #defaultPick (origin, hostnameRecords, affinity) {
  144. let ip = null
  145. const { records, offset } = hostnameRecords
  146. let family
  147. if (this.dualStack) {
  148. if (affinity == null) {
  149. // Balance between ip families
  150. if (offset == null || offset === maxInt) {
  151. hostnameRecords.offset = 0
  152. affinity = 4
  153. } else {
  154. hostnameRecords.offset++
  155. affinity = (hostnameRecords.offset & 1) === 1 ? 6 : 4
  156. }
  157. }
  158. if (records[affinity] != null && records[affinity].ips.length > 0) {
  159. family = records[affinity]
  160. } else {
  161. family = records[affinity === 4 ? 6 : 4]
  162. }
  163. } else {
  164. family = records[affinity]
  165. }
  166. // If no IPs we return null
  167. if (family == null || family.ips.length === 0) {
  168. return ip
  169. }
  170. if (family.offset == null || family.offset === maxInt) {
  171. family.offset = 0
  172. } else {
  173. family.offset++
  174. }
  175. const position = family.offset % family.ips.length
  176. ip = family.ips[position] ?? null
  177. if (ip == null) {
  178. return ip
  179. }
  180. if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms
  181. // We delete expired records
  182. // It is possible that they have different TTL, so we manage them individually
  183. family.ips.splice(position, 1)
  184. return this.pick(origin, hostnameRecords, affinity)
  185. }
  186. return ip
  187. }
  188. pickFamily (origin, ipFamily) {
  189. const records = this.storage.get(origin.hostname)?.records
  190. if (!records) {
  191. return null
  192. }
  193. const family = records[ipFamily]
  194. if (!family) {
  195. return null
  196. }
  197. if (family.offset == null || family.offset === maxInt) {
  198. family.offset = 0
  199. } else {
  200. family.offset++
  201. }
  202. const position = family.offset % family.ips.length
  203. const ip = family.ips[position] ?? null
  204. if (ip == null) {
  205. return ip
  206. }
  207. if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms
  208. // We delete expired records
  209. // It is possible that they have different TTL, so we manage them individually
  210. family.ips.splice(position, 1)
  211. }
  212. return ip
  213. }
  214. setRecords (origin, addresses) {
  215. const timestamp = Date.now()
  216. const records = { records: { 4: null, 6: null } }
  217. let minTTL = this.#maxTTL
  218. for (const record of addresses) {
  219. record.timestamp = timestamp
  220. if (typeof record.ttl === 'number') {
  221. // The record TTL is expected to be in ms
  222. record.ttl = Math.min(record.ttl, this.#maxTTL)
  223. minTTL = Math.min(minTTL, record.ttl)
  224. } else {
  225. record.ttl = this.#maxTTL
  226. }
  227. const familyRecords = records.records[record.family] ?? { ips: [] }
  228. familyRecords.ips.push(record)
  229. records.records[record.family] = familyRecords
  230. }
  231. // We provide a default TTL if external storage will be used without TTL per record-level support
  232. this.storage.set(origin.hostname, records, { ttl: minTTL })
  233. }
  234. deleteRecords (origin) {
  235. this.storage.delete(origin.hostname)
  236. }
  237. getHandler (meta, opts) {
  238. return new DNSDispatchHandler(this, meta, opts)
  239. }
  240. }
  241. class DNSDispatchHandler extends DecoratorHandler {
  242. #state = null
  243. #opts = null
  244. #dispatch = null
  245. #origin = null
  246. #controller = null
  247. #newOrigin = null
  248. #firstTry = true
  249. constructor (state, { origin, handler, dispatch, newOrigin }, opts) {
  250. super(handler)
  251. this.#origin = origin
  252. this.#newOrigin = newOrigin
  253. this.#opts = { ...opts }
  254. this.#state = state
  255. this.#dispatch = dispatch
  256. }
  257. onResponseError (controller, err) {
  258. switch (err.code) {
  259. case 'ETIMEDOUT':
  260. case 'ECONNREFUSED': {
  261. if (this.#state.dualStack) {
  262. if (!this.#firstTry) {
  263. super.onResponseError(controller, err)
  264. return
  265. }
  266. this.#firstTry = false
  267. // Pick an ip address from the other family
  268. const otherFamily = this.#newOrigin.hostname[0] === '[' ? 4 : 6
  269. const ip = this.#state.pickFamily(this.#origin, otherFamily)
  270. if (ip == null) {
  271. super.onResponseError(controller, err)
  272. return
  273. }
  274. let port
  275. if (typeof ip.port === 'number') {
  276. port = `:${ip.port}`
  277. } else if (this.#origin.port !== '') {
  278. port = `:${this.#origin.port}`
  279. } else {
  280. port = ''
  281. }
  282. const dispatchOpts = {
  283. ...this.#opts,
  284. origin: `${this.#origin.protocol}//${
  285. ip.family === 6 ? `[${ip.address}]` : ip.address
  286. }${port}`
  287. }
  288. this.#dispatch(dispatchOpts, this)
  289. return
  290. }
  291. // if dual-stack disabled, we error out
  292. super.onResponseError(controller, err)
  293. break
  294. }
  295. case 'ENOTFOUND':
  296. this.#state.deleteRecords(this.#origin)
  297. super.onResponseError(controller, err)
  298. break
  299. default:
  300. super.onResponseError(controller, err)
  301. break
  302. }
  303. }
  304. }
  305. module.exports = interceptorOpts => {
  306. if (
  307. interceptorOpts?.maxTTL != null &&
  308. (typeof interceptorOpts?.maxTTL !== 'number' || interceptorOpts?.maxTTL < 0)
  309. ) {
  310. throw new InvalidArgumentError('Invalid maxTTL. Must be a positive number')
  311. }
  312. if (
  313. interceptorOpts?.maxItems != null &&
  314. (typeof interceptorOpts?.maxItems !== 'number' ||
  315. interceptorOpts?.maxItems < 1)
  316. ) {
  317. throw new InvalidArgumentError(
  318. 'Invalid maxItems. Must be a positive number and greater than zero'
  319. )
  320. }
  321. if (
  322. interceptorOpts?.affinity != null &&
  323. interceptorOpts?.affinity !== 4 &&
  324. interceptorOpts?.affinity !== 6
  325. ) {
  326. throw new InvalidArgumentError('Invalid affinity. Must be either 4 or 6')
  327. }
  328. if (
  329. interceptorOpts?.dualStack != null &&
  330. typeof interceptorOpts?.dualStack !== 'boolean'
  331. ) {
  332. throw new InvalidArgumentError('Invalid dualStack. Must be a boolean')
  333. }
  334. if (
  335. interceptorOpts?.lookup != null &&
  336. typeof interceptorOpts?.lookup !== 'function'
  337. ) {
  338. throw new InvalidArgumentError('Invalid lookup. Must be a function')
  339. }
  340. if (
  341. interceptorOpts?.pick != null &&
  342. typeof interceptorOpts?.pick !== 'function'
  343. ) {
  344. throw new InvalidArgumentError('Invalid pick. Must be a function')
  345. }
  346. if (
  347. interceptorOpts?.storage != null &&
  348. (typeof interceptorOpts?.storage?.get !== 'function' ||
  349. typeof interceptorOpts?.storage?.set !== 'function' ||
  350. typeof interceptorOpts?.storage?.full !== 'function' ||
  351. typeof interceptorOpts?.storage?.delete !== 'function'
  352. )
  353. ) {
  354. throw new InvalidArgumentError('Invalid storage. Must be a object with methods: { get, set, full, delete }')
  355. }
  356. const dualStack = interceptorOpts?.dualStack ?? true
  357. let affinity
  358. if (dualStack) {
  359. affinity = interceptorOpts?.affinity ?? null
  360. } else {
  361. affinity = interceptorOpts?.affinity ?? 4
  362. }
  363. const opts = {
  364. maxTTL: interceptorOpts?.maxTTL ?? 10e3, // Expressed in ms
  365. lookup: interceptorOpts?.lookup ?? null,
  366. pick: interceptorOpts?.pick ?? null,
  367. dualStack,
  368. affinity,
  369. maxItems: interceptorOpts?.maxItems ?? Infinity,
  370. storage: interceptorOpts?.storage
  371. }
  372. const instance = new DNSInstance(opts)
  373. return dispatch => {
  374. return function dnsInterceptor (origDispatchOpts, handler) {
  375. const origin =
  376. origDispatchOpts.origin.constructor === URL
  377. ? origDispatchOpts.origin
  378. : new URL(origDispatchOpts.origin)
  379. if (isIP(origin.hostname) !== 0) {
  380. return dispatch(origDispatchOpts, handler)
  381. }
  382. instance.runLookup(origin, origDispatchOpts, (err, newOrigin) => {
  383. if (err) {
  384. return handler.onResponseError(null, err)
  385. }
  386. const dispatchOpts = {
  387. ...origDispatchOpts,
  388. servername: origin.hostname, // For SNI on TLS
  389. origin: newOrigin.origin,
  390. headers: {
  391. host: origin.host,
  392. ...origDispatchOpts.headers
  393. }
  394. }
  395. dispatch(
  396. dispatchOpts,
  397. instance.getHandler(
  398. { origin, dispatch, handler, newOrigin },
  399. origDispatchOpts
  400. )
  401. )
  402. })
  403. return true
  404. }
  405. }
  406. }