tangerine 1.4.5 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/index.js +166 -42
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -58,6 +58,7 @@
58
58
  * [`tangerine.setServers(servers)`](#tangerinesetserversservers)
59
59
  * [Options](#options)
60
60
  * [Cache](#cache)
61
+ * [Compatibility](#compatibility)
61
62
  * [Debugging](#debugging)
62
63
  * [Benchmarks](#benchmarks)
63
64
  * [Tangerine Benchmarks](#tangerine-benchmarks)
@@ -430,6 +431,15 @@ await tangerine.resolve('forwardemail.net'); // uses cached value
430
431
  This purge cache feature is useful for DNS records that have recently changed and have had their caches purged at the relevant DNS provider (e.g. [Cloudflare's Purge Cache tool](https://1.1.1.1/purge-cache/)).
431
432
 
432
433
 
434
+ ## Compatibility
435
+
436
+ The only known compatibility issue is for locally running DNS servers that have wildcard DNS matching.
437
+
438
+ If you are using `dnsmasq` with a wildcard match on "localhost" to "127.0.0.1", then the results may vary. For example, if your `dnsmasq` configuration has `address=/localhost/127.0.0.1`, then any match of `localhost` will resolve to `127.0.0.1`. This means that `dns.promises.lookup('foo.localhost')` will return `127.0.0.1` – however with :tangerine: Tangerine it will not return a value.
439
+
440
+ The reason is because :tangerine: Tangerine only looks at either `/etc/hosts` (macOS/Linux) and `C:/Windows/System32/drivers/etc/hosts` (Windows). It does not lookup BIND, dnsmasq, or other configurations running locally. We would welcome a PR to resolve this (see `isCI` usage in test folder) – however it is a non-issue, as the workaround is to simply append a new line to the hostfile of `127.0.0.1 foo.localhost`.
441
+
442
+
433
443
  ## Debugging
434
444
 
435
445
  If you run into issues while using :tangerine: Tangerine, then these recommendations may help:
package/index.js CHANGED
@@ -7,8 +7,11 @@ const { debuglog } = require('node:util');
7
7
  const { getEventListeners, setMaxListeners } = require('node:events');
8
8
  const { isIP, isIPv4, isIPv6 } = require('node:net');
9
9
 
10
+ const { toASCII } = require('punycode/');
11
+
10
12
  const autoBind = require('auto-bind');
11
13
  const getStream = require('get-stream');
14
+ const hostile = require('hostile');
12
15
  const ipaddr = require('ipaddr.js');
13
16
  const mergeOptions = require('merge-options');
14
17
  const pMap = require('p-map');
@@ -18,8 +21,6 @@ const packet = require('dns-packet');
18
21
  const semver = require('semver');
19
22
  const structuredClone = require('@ungap/structured-clone').default;
20
23
  const { getService } = require('port-numbers');
21
- // eslint-disable-next-line import/order
22
- const { toASCII } = require('punycode/');
23
24
 
24
25
  const pkg = require('./package.json');
25
26
 
@@ -32,8 +33,32 @@ import('dohdec').then((obj) => {
32
33
  dohdec = obj;
33
34
  });
34
35
 
36
+ // dynamically import private-ip
37
+ let isPrivateIP;
38
+ // eslint-disable-next-line unicorn/prefer-top-level-await
39
+ import('private-ip').then((obj) => {
40
+ isPrivateIP = obj.default;
41
+ });
42
+
43
+ const HOSTFILE = hostile
44
+ .get(true)
45
+ .map((s) => (Array.isArray(s) ? s.join(' ') : s))
46
+ .join('\n');
47
+
48
+ const HOSTS = [];
49
+ const hosts = hostile.get();
50
+ for (const line of hosts) {
51
+ const [ip, str] = line;
52
+ const hosts = str.split(' ');
53
+ HOSTS.push({ ip, hosts });
54
+ }
55
+
35
56
  // <https://github.com/szmarczak/cacheable-lookup/pull/76>
36
57
  class Tangerine extends dns.promises.Resolver {
58
+ static HOSTFILE = HOSTFILE;
59
+
60
+ static HOSTS = HOSTS;
61
+
37
62
  static isValidPort(port) {
38
63
  return Number.isSafeInteger(port) && port >= 0 && port <= 65535;
39
64
  }
@@ -148,7 +173,8 @@ class Tangerine extends dns.promises.Resolver {
148
173
  dns.NOTINITIALIZED,
149
174
  dns.REFUSED,
150
175
  dns.SERVFAIL,
151
- dns.TIMEOUT
176
+ dns.TIMEOUT,
177
+ 'EINVAL'
152
178
  ]);
153
179
 
154
180
  static DNS_TYPES = new Set([
@@ -606,6 +632,16 @@ class Tangerine extends dns.promises.Resolver {
606
632
  throw err;
607
633
  }
608
634
 
635
+ if (name === '.') {
636
+ const err = this.constructor.createError(name, '', dns.NOTFOUND);
637
+ // remap and perform syscall
638
+ err.syscall = 'getaddrinfo';
639
+ err.message = err.message.replace('query', 'getaddrinfo');
640
+ err.errno = -3008; // <-- ?
641
+ // err.errno = -3007;
642
+ throw err;
643
+ }
644
+
609
645
  // purge cache support
610
646
  let purgeCache;
611
647
  if (options?.purgeCache) {
@@ -639,50 +675,102 @@ class Tangerine extends dns.promises.Resolver {
639
675
  }
640
676
  }
641
677
 
678
+ // <https://github.com/c-ares/c-ares/blob/38b30bc922c21faa156939bde15ea35332c30e08/src/lib/ares_getaddrinfo.c#L407>
679
+ // <https://www.rfc-editor.org/rfc/rfc6761.html#section-6.3>
680
+ //
681
+ // > 'localhost and any domains falling within .localhost'
682
+ //
683
+ // if no system loopback match, then revert to the default
684
+ // <https://github.com/c-ares/c-ares/blob/38b30bc922c21faa156939bde15ea35332c30e08/src/lib/ares__addrinfo_localhost.c#L224-L229>
685
+ // - IPv4 = '127.0.0.1"
686
+ // - IPv6 = "::1"
687
+ //
688
+ let resolve4;
689
+ let resolve6;
690
+
691
+ const lower = name.toLowerCase();
692
+
693
+ for (const rule of this.constructor.HOSTS) {
694
+ if (rule.hosts.every((h) => h.toLowerCase() !== lower)) continue;
695
+ const type = isIP(rule.ip);
696
+ if (!resolve4 && type === 4) {
697
+ if (!Array.isArray(resolve4)) resolve4 = [rule.ip];
698
+ else if (!resolve4.includes(rule.ip)) resolve4.push([rule.ip]);
699
+ } else if (!resolve6 && type === 6) {
700
+ if (!Array.isArray(resolve6)) resolve6 = [rule.ip];
701
+ else if (!resolve6.includes(rule.ip)) resolve6.push(rule.ip);
702
+ }
703
+ }
704
+
705
+ // safeguard (matches c-ares)
706
+ if (lower === 'localhost' || lower === 'localhost.') {
707
+ if (!resolve4) resolve4 = ['127.0.0.1'];
708
+ if (!resolve6) resolve6 = ['::1'];
709
+ }
710
+
711
+ if (isIPv4(name)) {
712
+ resolve4 = [name];
713
+ resolve6 = [];
714
+ } else if (isIPv6(name)) {
715
+ resolve6 = [name];
716
+ resolve4 = [];
717
+ }
718
+
642
719
  // resolve the first A or AAAA record (conditionally)
643
- let answers = [];
720
+ const results = await Promise.all(
721
+ [
722
+ Array.isArray(resolve4)
723
+ ? Promise.resolve(resolve4)
724
+ : this.resolve4(name, { purgeCache, noThrowOnNODATA: true }),
725
+ Array.isArray(resolve6)
726
+ ? Promise.resolve(resolve6)
727
+ : this.resolve6(name, { purgeCache, noThrowOnNODATA: true })
728
+ ].map((p) => p.catch((err) => err))
729
+ );
644
730
 
645
- try {
646
- answers = await Promise.all([
647
- this.resolve4(name, { purgeCache, noThrowOnNODATA: true }),
648
- this.resolve6(name, { purgeCache, noThrowOnNODATA: true })
649
- ]);
650
- // default node behavior seems to return IPv4 by default always regardless
651
- answers =
652
- answers[0].length > 0 &&
653
- (typeof options.family === 'undefined' || options.family === 0)
654
- ? answers[0]
655
- : answers.flat();
656
- } catch (_err) {
657
- debug(_err);
658
-
659
- // this will most likely be instanceof AggregateError
660
- if (_err instanceof AggregateError) {
661
- const err = this.constructor.combineErrors(_err.errors);
662
- err.hostname = name;
663
- // remap and perform syscall
664
- err.syscall = 'getaddrinfo';
665
- if (!err.code)
666
- err.code = _err.errors.find((e) => e.code)?.code || dns.BADRESP;
667
- if (!err.errno)
668
- err.errno = _err.errors.find((e) => e.errno)?.errno || undefined;
731
+ const errors = [];
732
+ let answers = [];
669
733
 
670
- throw err;
734
+ for (const result of results) {
735
+ if (result instanceof Error) {
736
+ errors.push(result);
737
+ } else {
738
+ answers.push(result);
671
739
  }
740
+ }
672
741
 
673
- const err = this.constructor.createError(name, '', _err.code, _err.errno);
742
+ if (
743
+ answers.length === 0 &&
744
+ errors.length > 0 &&
745
+ errors.every((e) => e.code === errors[0].code)
746
+ ) {
747
+ const err = this.constructor.createError(
748
+ name,
749
+ '',
750
+ errors[0].code === dns.BADNAME ? dns.NOTFOUND : errors[0].code
751
+ );
674
752
  // remap and perform syscall
675
753
  err.syscall = 'getaddrinfo';
676
- err.error = _err;
754
+ err.message = err.message.replace('query', 'getaddrinfo');
755
+ err.errno = -3008;
677
756
  throw err;
678
757
  }
679
758
 
759
+ // default node behavior seems to return IPv4 by default always regardless
760
+ if (answers.length > 0)
761
+ answers =
762
+ answers[0].length > 0 &&
763
+ (typeof options.family === 'undefined' || options.family === 0)
764
+ ? answers[0]
765
+ : answers.flat();
766
+
680
767
  // if no results then throw ENODATA
681
768
  if (answers.length === 0) {
682
769
  const err = this.constructor.createError(name, '', dns.NODATA);
683
770
  // remap and perform syscall
684
771
  err.syscall = 'getaddrinfo';
685
- // err.errno = -3008;
772
+ err.message = err.message.replace('query', 'getaddrinfo');
773
+ err.errno = -3008;
686
774
  throw err;
687
775
  }
688
776
 
@@ -835,15 +923,37 @@ class Tangerine extends dns.promises.Resolver {
835
923
  }
836
924
 
837
925
  if (!isIP(ip)) {
838
- const err = this.constructor.createError(ip, '', dns.EINVAL);
926
+ const err = this.constructor.createError(ip, '', 'EINVAL');
927
+ err.message = `getHostByAddr EINVAL ${err.hostname}`;
839
928
  err.syscall = 'getHostByAddr';
840
- // err.errno = -22;
929
+ err.errno = -22;
841
930
  if (!ip) delete err.hostname;
842
931
  throw err;
843
932
  }
844
933
 
934
+ // edge case where localhost IP returns matches
935
+ if (!isPrivateIP) await pWaitFor(() => Boolean(isPrivateIP));
936
+
937
+ const answers = new Set();
938
+ let match = false;
939
+
940
+ for (const rule of this.constructor.HOSTS) {
941
+ if (rule.ip === ip) {
942
+ match = true;
943
+ for (const host of rule.hosts.slice(1)) {
944
+ answers.add(host);
945
+ }
946
+ }
947
+ }
948
+
949
+ if (answers.size > 0 || match) return [...answers];
950
+
951
+ // NOTE: we can prob remove this (?)
952
+ // if (ip === '::1' || ip === '127.0.0.1') return [];
953
+
845
954
  // reverse the IP address
846
955
  if (!dohdec) await pWaitFor(() => Boolean(dohdec));
956
+
847
957
  const name = dohdec.DNSoverHTTPS.reverse(ip);
848
958
 
849
959
  // perform resolvePTR
@@ -1270,13 +1380,22 @@ class Tangerine extends dns.promises.Resolver {
1270
1380
  );
1271
1381
  }
1272
1382
 
1273
- const results = await pMap(
1274
- this.constructor.ANY_TYPES,
1275
- this.#resolveByType(name, options, abortController),
1276
- // <https://developers.cloudflare.com/fundamentals/api/reference/limits/>
1277
- { concurrency: this.options.concurrency, signal: abortController.signal }
1278
- );
1279
- return results.flat().filter(Boolean);
1383
+ try {
1384
+ const results = await pMap(
1385
+ this.constructor.ANY_TYPES,
1386
+ this.#resolveByType(name, options, abortController),
1387
+ // <https://developers.cloudflare.com/fundamentals/api/reference/limits/>
1388
+ {
1389
+ concurrency: this.options.concurrency,
1390
+ signal: abortController.signal
1391
+ }
1392
+ );
1393
+ return results.flat().filter(Boolean);
1394
+ } catch (err) {
1395
+ err.syscall = 'queryAny';
1396
+ err.message = `queryAny ${err.code} ${name}`;
1397
+ throw err;
1398
+ }
1280
1399
  }
1281
1400
 
1282
1401
  setDefaultResultOrder(dnsOrder) {
@@ -1330,6 +1449,11 @@ class Tangerine extends dns.promises.Resolver {
1330
1449
  throw err;
1331
1450
  }
1332
1451
 
1452
+ // edge case where c-ares detects "." as start of string
1453
+ // <https://github.com/c-ares/c-ares/blob/38b30bc922c21faa156939bde15ea35332c30e08/src/lib/ares_getaddrinfo.c#L829>
1454
+ if (name !== '.' && (name.startsWith('.') || name.includes('..')))
1455
+ throw this.constructor.createError(name, rrtype, dns.BADNAME);
1456
+
1333
1457
  // purge cache support
1334
1458
  let purgeCache;
1335
1459
  if (options?.purgeCache) {
@@ -1712,7 +1836,7 @@ class Tangerine extends dns.promises.Resolver {
1712
1836
  : obj.certificate_type.toString();
1713
1837
  return obj;
1714
1838
  } catch (err) {
1715
- console.error(err);
1839
+ this.options.logger.error(err, { name, rrtype, options, answer });
1716
1840
  throw err;
1717
1841
  }
1718
1842
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tangerine",
3
3
  "description": "Tangerine is the best Node.js drop-in replacement for dns.promises.Resolver using DNS over HTTPS (\"DoH\") via undici with built-in retries, timeouts, smart server rotation, AbortControllers, and caching support for multiple backends (with TTL and purge support).",
4
- "version": "1.4.5",
4
+ "version": "1.4.7",
5
5
  "author": "Forward Email (https://forwardemail.net)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/forwardemail/tangerine/issues"
@@ -15,12 +15,14 @@
15
15
  "dns-packet": "^5.4.0",
16
16
  "dohdec": "^5.0.3",
17
17
  "get-stream": "6",
18
+ "hostile": "^1.3.3",
18
19
  "ipaddr.js": "^2.0.1",
19
20
  "merge-options": "3.0.4",
20
21
  "p-map": "4",
21
22
  "p-timeout": "4",
22
23
  "p-wait-for": "3",
23
24
  "port-numbers": "^6.0.1",
25
+ "private-ip": "^3.0.0",
24
26
  "punycode": "^2.3.0",
25
27
  "semver": "^7.3.8"
26
28
  },
@@ -39,6 +41,7 @@
39
41
  "husky": "^8.0.3",
40
42
  "ioredis": "^5.3.1",
41
43
  "ioredis-mock": "^8.2.6",
44
+ "is-ci": "^3.0.1",
42
45
  "lint-staged": "^13.1.2",
43
46
  "lodash": "^4.17.21",
44
47
  "nock": "^13.3.0",