is-domain-alive.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import tldts from 'tldts-experimental';
  2. import { looseTldtsOpt } from '../constants/loose-tldts-opt';
  3. import picocolors from 'picocolors';
  4. import DNS2 from 'dns2';
  5. import asyncRetry from 'async-retry';
  6. import * as whoiser from 'whoiser';
  7. import { createRetrieKeywordFilter as createKeywordFilter } from 'foxts/retrie';
  8. const mutex = new Map<string, Promise<unknown>>();
  9. export function keyedAsyncMutexWithQueue<T>(key: string, fn: () => Promise<T>) {
  10. if (mutex.has(key)) {
  11. return mutex.get(key) as Promise<T>;
  12. }
  13. const promise = fn();
  14. mutex.set(key, promise);
  15. return promise;
  16. }
  17. class DnsError extends Error {
  18. name = 'DnsError';
  19. constructor(readonly message: string, public readonly server: string) {
  20. super(message);
  21. }
  22. }
  23. interface DnsResponse extends DNS2.$DnsResponse {
  24. dns: string
  25. }
  26. const dohServers: Array<[string, DNS2.DnsResolver]> = ([
  27. '8.8.8.8',
  28. '8.8.4.4',
  29. '1.0.0.1',
  30. '1.1.1.1',
  31. '162.159.36.1',
  32. '162.159.46.1',
  33. '101.101.101.101', // TWNIC
  34. '185.222.222.222', // DNS.SB
  35. '45.11.45.11', // DNS.SB
  36. 'dns10.quad9.net', // Quad9 unfiltered
  37. 'doh.sandbox.opendns.com', // OpenDNS sandbox (unfiltered)
  38. 'unfiltered.adguard-dns.com',
  39. // '0ms.dev', // Proxy Cloudflare
  40. // '76.76.2.0', // ControlD unfiltered, path not /dns-query
  41. // '76.76.10.0', // ControlD unfiltered, path not /dns-query
  42. // 'dns.bebasid.com', // BebasID, path not /dns-query but /unfiltered
  43. // '193.110.81.0', // dns0.eu
  44. // '185.253.5.0', // dns0.eu
  45. // 'zero.dns0.eu',
  46. 'dns.nextdns.io',
  47. 'anycast.dns.nextdns.io',
  48. 'wikimedia-dns.org',
  49. // 'ordns.he.net',
  50. // 'dns.mullvad.net',
  51. 'basic.rethinkdns.com'
  52. // 'ada.openbld.net',
  53. // 'dns.rabbitdns.org'
  54. ] as const).map(dns => [
  55. dns,
  56. DNS2.DOHClient({
  57. dns,
  58. http: false
  59. // get: (url: string) => undici.request(url).then(r => r.body)
  60. })
  61. ] as const);
  62. const domesticDohServers: Array<[string, DNS2.DnsResolver]> = ([
  63. '223.5.5.5',
  64. '223.6.6.6',
  65. '120.53.53.53',
  66. '1.12.12.12'
  67. ] as const).map(dns => [
  68. dns,
  69. DNS2.DOHClient({
  70. dns,
  71. http: false
  72. // get: (url: string) => undici.request(url).then(r => r.body)
  73. })
  74. ] as const);
  75. function createResolve(server: Array<[string, DNS2.DnsResolver]>): DNS2.DnsResolver<DnsResponse> {
  76. return async (...args) => {
  77. try {
  78. return await asyncRetry(async () => {
  79. const [dohServer, dohClient] = server[Math.floor(Math.random() * server.length)];
  80. try {
  81. return {
  82. ...await dohClient(...args),
  83. dns: dohServer
  84. } satisfies DnsResponse;
  85. } catch (e) {
  86. // console.error(e);
  87. throw new DnsError((e as Error).message, dohServer);
  88. }
  89. }, { retries: 5 });
  90. } catch (e) {
  91. console.log('[doh error]', ...args, e);
  92. throw e;
  93. }
  94. };
  95. }
  96. const resolve = createResolve(dohServers);
  97. const domesticResolve = createResolve(domesticDohServers);
  98. async function getWhois(domain: string) {
  99. return asyncRetry(() => whoiser.domain(domain), { retries: 5 });
  100. }
  101. const domainAliveMap = new Map<string, boolean>();
  102. function onDomainAlive(domain: string): [string, boolean] {
  103. domainAliveMap.set(domain, true);
  104. return [domain, true];
  105. }
  106. function onDomainDead(domain: string): [string, boolean] {
  107. domainAliveMap.set(domain, false);
  108. return [domain, false];
  109. }
  110. export async function isDomainAlive(domain: string, isSuffix: boolean): Promise<[string, boolean]> {
  111. if (domainAliveMap.has(domain)) {
  112. return [domain, domainAliveMap.get(domain)!];
  113. }
  114. const apexDomain = tldts.getDomain(domain, looseTldtsOpt);
  115. if (!apexDomain) {
  116. console.log(picocolors.gray('[domain invalid]'), picocolors.gray('no apex domain'), { domain });
  117. return onDomainAlive(domain);
  118. }
  119. const apexDomainAlive = await keyedAsyncMutexWithQueue(apexDomain, () => isApexDomainAlive(apexDomain));
  120. if (isSuffix) {
  121. return apexDomainAlive;
  122. }
  123. if (!apexDomainAlive[1]) {
  124. return apexDomainAlive;
  125. }
  126. const $domain = domain[0] === '.' ? domain.slice(1) : domain;
  127. const aDns: string[] = [];
  128. const aaaaDns: string[] = [];
  129. // test 2 times before make sure record is empty
  130. for (let i = 0; i < 2; i++) {
  131. // eslint-disable-next-line no-await-in-loop -- sequential
  132. const aRecords = (await resolve($domain, 'A'));
  133. if (aRecords.answers.length > 0) {
  134. return onDomainAlive(domain);
  135. }
  136. aDns.push(aRecords.dns);
  137. }
  138. for (let i = 0; i < 2; i++) {
  139. // eslint-disable-next-line no-await-in-loop -- sequential
  140. const aaaaRecords = (await resolve($domain, 'AAAA'));
  141. if (aaaaRecords.answers.length > 0) {
  142. return onDomainAlive(domain);
  143. }
  144. aaaaDns.push(aaaaRecords.dns);
  145. }
  146. // only then, let's test once with domesticDohServers
  147. const aRecords = (await domesticResolve($domain, 'A'));
  148. if (aRecords.answers.length > 0) {
  149. return onDomainAlive(domain);
  150. }
  151. aDns.push(aRecords.dns);
  152. const aaaaRecords = (await domesticResolve($domain, 'AAAA'));
  153. if (aaaaRecords.answers.length > 0) {
  154. return onDomainAlive(domain);
  155. }
  156. aaaaDns.push(aaaaRecords.dns);
  157. console.log(picocolors.red('[domain dead]'), 'no A/AAAA records', { domain, a: aDns, aaaa: aaaaDns });
  158. return onDomainDead($domain);
  159. }
  160. const apexDomainNsResolvePromiseMap = new Map<string, Promise<DnsResponse>>();
  161. async function isApexDomainAlive(apexDomain: string): Promise<[string, boolean]> {
  162. if (domainAliveMap.has(apexDomain)) {
  163. return [apexDomain, domainAliveMap.get(apexDomain)!];
  164. }
  165. let resp: DnsResponse;
  166. if (apexDomainNsResolvePromiseMap.has(apexDomain)) {
  167. resp = await apexDomainNsResolvePromiseMap.get(apexDomain)!;
  168. } else {
  169. const promise = resolve(apexDomain, 'NS');
  170. apexDomainNsResolvePromiseMap.set(apexDomain, promise);
  171. resp = await promise;
  172. }
  173. if (resp.answers.length > 0) {
  174. return onDomainAlive(apexDomain);
  175. }
  176. let whois;
  177. try {
  178. whois = await getWhois(apexDomain);
  179. } catch (e) {
  180. console.log(picocolors.red('[domain dead]'), 'whois error', { domain: apexDomain }, e);
  181. return onDomainDead(apexDomain);
  182. }
  183. // console.log(JSON.stringify(whois, null, 2));
  184. if (whoisExists(whois)) {
  185. console.log(picocolors.gray('[domain alive]'), 'whois found', { domain: apexDomain });
  186. return onDomainAlive(apexDomain);
  187. }
  188. console.log(picocolors.red('[domain dead]'), 'whois not found', { domain: apexDomain });
  189. return onDomainDead(apexDomain);
  190. }
  191. // TODO: this is a workaround for https://github.com/LayeredStudio/whoiser/issues/117
  192. const whoisNotFoundKeywordTest = createKeywordFilter([
  193. 'no match for',
  194. 'does not exist',
  195. 'not found',
  196. 'no entries',
  197. 'no data found',
  198. 'is available for registration',
  199. 'currently available for application'
  200. ]);
  201. export function whoisExists(whois: whoiser.WhoisSearchResult) {
  202. let empty = true;
  203. for (const key in whois) {
  204. if (Object.hasOwn(whois, key)) {
  205. empty = false;
  206. if (key === 'error') {
  207. if (
  208. (typeof whois.error === 'string' && whois.error)
  209. || (Array.isArray(whois.error) && whois.error.length > 0)
  210. ) {
  211. console.error(whois);
  212. return true;
  213. }
  214. continue;
  215. }
  216. if (key === 'text') {
  217. if (Array.isArray(whois.text) && whois.text.some(value => whoisNotFoundKeywordTest(value.toLowerCase()))) {
  218. return false;
  219. }
  220. continue;
  221. }
  222. if (key === 'Name Server') {
  223. if (Array.isArray(whois[key]) && whois[key].length === 0) {
  224. return false;
  225. }
  226. continue;
  227. }
  228. if (typeof whois[key] === 'object' && !Array.isArray(whois[key]) && !whoisExists(whois[key])) {
  229. return false;
  230. }
  231. }
  232. }
  233. return !empty;
  234. }