build-domestic-direct-lan-ruleset-dns-mapping-module.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. // @ts-check
  2. import path from 'node:path';
  3. import { DOMESTICS, DOH_BOOTSTRAP, AdGuardHomeDNSMapping } from '../Source/non_ip/domestic';
  4. import { DIRECTS, HOSTS, LAN } from '../Source/non_ip/direct';
  5. import type { DNSMapping } from '../Source/non_ip/direct';
  6. import { fetchRemoteTextByLine, readFileIntoProcessedArray } from './lib/fetch-text-by-line';
  7. import { compareAndWriteFile } from './lib/create-file';
  8. import { task } from './trace';
  9. import type { Span } from './trace';
  10. import { SHARED_DESCRIPTION } from './constants/description';
  11. import { once } from 'foxts/once';
  12. import * as yaml from 'yaml';
  13. import { appendArrayInPlace } from 'foxts/append-array-in-place';
  14. import { OUTPUT_INTERNAL_DIR, OUTPUT_MODULES_DIR, OUTPUT_MODULES_RULES_DIR, SOURCE_DIR } from './constants/dir';
  15. import { MihomoNameserverPolicyOutput, RulesetOutput, SurgeOnlyRulesetOutput } from './lib/rules/ruleset';
  16. import { $$fetch } from './lib/fetch-retry';
  17. export function createGetDnsMappingRule(allowWildcard: boolean) {
  18. const hasWildcard = (domain: string) => {
  19. if (domain.includes('*') || domain.includes('?')) {
  20. if (!allowWildcard) {
  21. throw new TypeError(`Wildcard domain is not supported: ${domain}`);
  22. }
  23. return true;
  24. }
  25. return false;
  26. };
  27. return (domain: string): string[] => {
  28. const results: string[] = [];
  29. if (domain[0] === '$') {
  30. const d = domain.slice(1);
  31. if (hasWildcard(domain)) {
  32. results.push(`DOMAIN-WILDCARD,${d}`);
  33. } else {
  34. results.push(`DOMAIN,${d}`);
  35. }
  36. } else if (domain[0] === '+') {
  37. const d = domain.slice(1);
  38. if (hasWildcard(domain)) {
  39. results.push(`DOMAIN-WILDCARD,*.${d}`);
  40. } else {
  41. results.push(`DOMAIN-SUFFIX,${d}`);
  42. }
  43. } else if (hasWildcard(domain)) {
  44. results.push(`DOMAIN-WILDCARD,${domain}`, `DOMAIN-WILDCARD,*.${domain}`);
  45. } else {
  46. results.push(`DOMAIN-SUFFIX,${domain}`);
  47. }
  48. return results;
  49. };
  50. }
  51. export const getDomesticAndDirectDomainsRulesetPromise = once(async () => {
  52. const domestics = await readFileIntoProcessedArray(path.join(SOURCE_DIR, 'non_ip/domestic.conf'));
  53. const directs = await readFileIntoProcessedArray(path.resolve(SOURCE_DIR, 'non_ip/direct.conf'));
  54. const lans: string[] = [];
  55. const getDnsMappingRuleWithWildcard = createGetDnsMappingRule(true);
  56. [DOH_BOOTSTRAP, DOMESTICS].forEach((item) => {
  57. Object.values(item).forEach(({ domains }) => {
  58. appendArrayInPlace(domestics, domains.flatMap(getDnsMappingRuleWithWildcard));
  59. });
  60. });
  61. Object.values(DIRECTS).forEach(({ domains }) => {
  62. appendArrayInPlace(directs, domains.flatMap(getDnsMappingRuleWithWildcard));
  63. });
  64. Object.values(LAN).forEach(({ domains }) => {
  65. appendArrayInPlace(directs, domains.flatMap(getDnsMappingRuleWithWildcard));
  66. // backward compatible, add lan.conf
  67. appendArrayInPlace(lans, domains.flatMap(getDnsMappingRuleWithWildcard));
  68. });
  69. return [domestics, directs, lans] as const;
  70. });
  71. export const buildDomesticRuleset = task(require.main === module, __filename)(async (span) => {
  72. const [domestics, directs, lans] = await getDomesticAndDirectDomainsRulesetPromise();
  73. const dataset: Array<[name: string, DNSMapping]> = ([DOH_BOOTSTRAP, DOMESTICS, DIRECTS, LAN, HOSTS] as const).flatMap(Object.entries);
  74. return Promise.all([
  75. new RulesetOutput(span, 'domestic', 'non_ip')
  76. .withTitle('Sukka\'s Ruleset - Domestic Domains')
  77. .appendDescription(
  78. SHARED_DESCRIPTION,
  79. '',
  80. 'This file contains known addresses that are avaliable in the Mainland China.'
  81. )
  82. .addFromRuleset(domestics)
  83. .write(),
  84. new RulesetOutput(span, 'direct', 'non_ip')
  85. .withTitle('Sukka\'s Ruleset - Direct Rules')
  86. .appendDescription(
  87. SHARED_DESCRIPTION,
  88. '',
  89. 'This file contains domains and process that should not be proxied.'
  90. )
  91. .addFromRuleset(directs)
  92. .write(),
  93. new RulesetOutput(span, 'lan', 'non_ip')
  94. .withTitle('Sukka\'s Ruleset - LAN')
  95. .appendDescription(
  96. SHARED_DESCRIPTION,
  97. '',
  98. 'This file includes rules for LAN DOMAIN and reserved TLDs.'
  99. )
  100. .addFromRuleset(lans)
  101. .write(),
  102. buildLANCacheRuleset(span),
  103. ...dataset.map(([name, { ruleset, domains }]) => {
  104. if (!ruleset) {
  105. return;
  106. }
  107. const surgeOutput = new SurgeOnlyRulesetOutput(
  108. span,
  109. name.toLowerCase(),
  110. 'sukka_local_dns_mapping',
  111. OUTPUT_MODULES_RULES_DIR
  112. )
  113. .withTitle(`Sukka's Ruleset - Local DNS Mapping (${name})`)
  114. .appendDescription(
  115. SHARED_DESCRIPTION,
  116. '',
  117. 'This is an internal rule that is only referenced by sukka_local_dns_mapping.sgmodule',
  118. 'Do not use this file in your Rule section, all entries are included in non_ip/domestic.conf already.'
  119. );
  120. const mihomoOutput = new MihomoNameserverPolicyOutput(
  121. span,
  122. name.toLowerCase(),
  123. 'mihomo_nameserver_policy',
  124. OUTPUT_INTERNAL_DIR
  125. )
  126. .withTitle(`Sukka's Ruleset - Local DNS Mapping for Mihomo NameServer Policy (${name})`)
  127. .appendDescription(
  128. SHARED_DESCRIPTION,
  129. '',
  130. 'This ruleset is only used for mihomo\'s nameserver-policy feature, which',
  131. 'is similar to the RULE-SET referenced by sukka_local_dns_mapping.sgmodule.',
  132. 'Do not use this file in your Rule section, all entries are included in non_ip/domestic.conf already.'
  133. );
  134. domains.forEach((domain) => {
  135. const isWildcard = domain.includes('*') || domain.includes('?');
  136. switch (domain[0]) {
  137. case '$': {
  138. const d = domain.slice(1);
  139. if (isWildcard) {
  140. surgeOutput.addDomainWildcard(d);
  141. mihomoOutput.addDomainWildcard(d);
  142. } else {
  143. surgeOutput.addDomain(d);
  144. mihomoOutput.addDomain(d);
  145. }
  146. break;
  147. }
  148. case '+': {
  149. const d = domain.slice(1);
  150. if (isWildcard) {
  151. surgeOutput.addDomainWildcard(`*.${d}`);
  152. mihomoOutput.addDomainWildcard(`*.${d}`);
  153. } else {
  154. surgeOutput.addDomainSuffix(d);
  155. mihomoOutput.addDomainSuffix(d);
  156. }
  157. break;
  158. }
  159. default:
  160. if (isWildcard) {
  161. surgeOutput.addDomainWildcard(domain);
  162. surgeOutput.addDomainWildcard(`*.${domain}`);
  163. mihomoOutput.addDomainWildcard(domain);
  164. mihomoOutput.addDomainWildcard(`*.${domain}`);
  165. } else {
  166. surgeOutput.addDomainSuffix(domain);
  167. mihomoOutput.addDomainSuffix(domain);
  168. }
  169. break;
  170. }
  171. });
  172. return Promise.all([
  173. surgeOutput.write(),
  174. mihomoOutput.write()
  175. ]);
  176. }),
  177. compareAndWriteFile(
  178. span,
  179. [
  180. '#!name=[Sukka] Local DNS Mapping',
  181. `#!desc=Last Updated: ${new Date().toISOString()}`,
  182. '',
  183. '[Host]',
  184. ...Object.entries(
  185. // I use an object to deduplicate the domains
  186. // Otherwise I could just construct an array directly
  187. dataset.reduce<Record<string, string>>((acc, cur) => {
  188. const ruleset_name = cur[0].toLowerCase();
  189. const { domains, dns, hosts, ruleset } = cur[1];
  190. Object.entries(hosts).forEach(([dns, ips]) => {
  191. acc[dns] ||= ips.join(', ');
  192. });
  193. if (ruleset) {
  194. acc[`RULE-SET:https://ruleset.skk.moe/Modules/Rules/sukka_local_dns_mapping/${ruleset_name}.conf`] ||= `server:${dns}`;
  195. } else {
  196. domains.forEach((domain) => {
  197. switch (domain[0]) {
  198. case '$':
  199. acc[domain.slice(1)] ||= `server:${dns}`;
  200. break;
  201. case '+':
  202. acc[`*.${domain.slice(1)}`] ||= `server:${dns}`;
  203. break;
  204. default:
  205. acc[domain] ||= `server:${dns}`;
  206. acc[`*.${domain}`] ||= `server:${dns}`;
  207. break;
  208. }
  209. });
  210. }
  211. return acc;
  212. }, {})
  213. ).map(([dns, ips]) => `${dns} = ${ips}`)
  214. ],
  215. path.resolve(OUTPUT_MODULES_DIR, 'sukka_local_dns_mapping.sgmodule')
  216. ),
  217. compareAndWriteFile(
  218. span,
  219. yaml.stringify(
  220. dataset.reduce<{
  221. dns: { 'nameserver-policy': Record<string, string | string[]> },
  222. hosts: Record<string, string | string[]>,
  223. 'rule-providers': Record<string, {
  224. type: 'http',
  225. path: `./sukkaw_ruleset/${string}`,
  226. url: string,
  227. behavior: 'classical',
  228. format: 'text',
  229. interval: number
  230. }>
  231. }>((acc, cur) => {
  232. const { domains, dns, ruleset, ...rest } = cur[1];
  233. if (ruleset) {
  234. const ruleset_name = cur[0].toLowerCase();
  235. const mihomo_ruleset_id = `mihomo_nameserver_policy_${ruleset_name}`;
  236. if (dns) {
  237. acc.dns['nameserver-policy'][`rule-set:${mihomo_ruleset_id}`] = dns;
  238. }
  239. acc['rule-providers'][mihomo_ruleset_id] = {
  240. type: 'http',
  241. path: `./sukkaw_ruleset/${mihomo_ruleset_id}.txt`,
  242. url: `https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/${ruleset_name}.txt`,
  243. behavior: 'classical',
  244. format: 'text',
  245. interval: 43200
  246. };
  247. } else {
  248. domains.forEach((domain) => {
  249. switch (domain[0]) {
  250. case '$':
  251. domain = domain.slice(1);
  252. break;
  253. case '+':
  254. domain = `*.${domain.slice(1)}`;
  255. break;
  256. default:
  257. domain = `+.${domain}`;
  258. break;
  259. }
  260. if (dns) {
  261. acc.dns['nameserver-policy'][domain] = dns;
  262. }
  263. });
  264. }
  265. if ('hosts' in rest) {
  266. // eslint-disable-next-line guard-for-in -- known plain object
  267. for (const domain in rest.hosts) {
  268. const dest = rest.hosts[domain];
  269. if (domain in acc.hosts) {
  270. if (typeof acc.hosts[domain] === 'string') {
  271. acc.hosts[domain] = [acc.hosts[domain]];
  272. }
  273. appendArrayInPlace(acc.hosts[domain], dest);
  274. } else if (dest.length === 1) {
  275. acc.hosts[domain] = dest[0];
  276. } else {
  277. acc.hosts[domain] = dest;
  278. }
  279. }
  280. }
  281. return acc;
  282. }, {
  283. dns: { 'nameserver-policy': {} },
  284. 'rule-providers': {},
  285. hosts: {}
  286. }),
  287. { version: '1.1' }
  288. ).split('\n'),
  289. path.join(OUTPUT_INTERNAL_DIR, 'clash_nameserver_policy.yaml')
  290. ),
  291. compareAndWriteFile(
  292. span,
  293. [
  294. '# Local DNS Mapping for AdGuard Home',
  295. 'https://doh.pub/dns-query',
  296. 'https://dns.alidns.com/dns-query',
  297. '[//]udp://10.10.1.1:53',
  298. ...(([DOMESTICS, DIRECTS, LAN, HOSTS] as const).flatMap(Object.values) as DNSMapping[]).flatMap(({ domains, dns: _dns }) => domains.flatMap((domain) => {
  299. if (!_dns) {
  300. return [];
  301. }
  302. let dns;
  303. if (_dns in AdGuardHomeDNSMapping) {
  304. dns = AdGuardHomeDNSMapping[_dns as keyof typeof AdGuardHomeDNSMapping].join(' ');
  305. } else {
  306. console.warn(`Unknown DNS "${_dns}" not in AdGuardHomeDNSMapping`);
  307. dns = _dns;
  308. }
  309. // if (
  310. // // AdGuard Home has built-in AS112 / private PTR handling
  311. // domain.endsWith('.arpa')
  312. // // Ignore simple hostname
  313. // || !domain.includes('.')
  314. // ) {
  315. // return [];
  316. // }
  317. if (domain[0] === '$') {
  318. return [
  319. `[/${domain.slice(1)}/]${dns}`
  320. ];
  321. }
  322. if (domain[0] === '+') {
  323. return [
  324. `[/${domain.slice(1)}/]${dns}`
  325. ];
  326. }
  327. return [
  328. `[/${domain}/]${dns}`
  329. ];
  330. }))
  331. ],
  332. path.resolve(OUTPUT_INTERNAL_DIR, 'dns_mapping_adguardhome.conf')
  333. )
  334. ]);
  335. });
  336. async function buildLANCacheRuleset(span: Span) {
  337. const childSpan = span.traceChild('build LAN cache ruleset');
  338. const cacheDomainsData = await childSpan.traceChildAsync('fetch cache_domains.json', async () => (await $$fetch('https://cdn.jsdelivr.net/gh/uklans/cache-domains@master/cache_domains.json')).json());
  339. if (!cacheDomainsData || typeof cacheDomainsData !== 'object' || !('cache_domains' in cacheDomainsData) || !Array.isArray(cacheDomainsData.cache_domains)) {
  340. throw new TypeError('Invalid cache domains data');
  341. }
  342. const allDomainFiles = cacheDomainsData.cache_domains.reduce<string[]>((acc, { domain_files }) => {
  343. if (Array.isArray(domain_files)) {
  344. appendArrayInPlace(acc, domain_files);
  345. }
  346. return acc;
  347. }, []);
  348. const allDomains = (
  349. await Promise.all(
  350. allDomainFiles.map(
  351. async (file) => childSpan.traceChildAsync(
  352. 'download ' + file,
  353. async () => Array.fromAsync(await fetchRemoteTextByLine('https://cdn.jsdelivr.net/gh/uklans/cache-domains@master/' + file, true))
  354. )
  355. )
  356. )
  357. ).flat();
  358. const surgeOutput = new SurgeOnlyRulesetOutput(
  359. span,
  360. 'lancache',
  361. 'sukka_local_dns_mapping',
  362. OUTPUT_MODULES_RULES_DIR
  363. )
  364. .withTitle('Sukka\'s Ruleset - Local DNS Mapping (lancache)')
  365. .appendDescription(
  366. SHARED_DESCRIPTION,
  367. '',
  368. 'This is an internal rule that is only referenced by sukka_local_dns_mapping.sgmodule',
  369. 'Do not use this file in your Rule section.'
  370. );
  371. const mihomoOutput = new MihomoNameserverPolicyOutput(
  372. span,
  373. 'lancache',
  374. 'mihomo_nameserver_policy',
  375. OUTPUT_INTERNAL_DIR
  376. )
  377. .withTitle('Sukka\'s Ruleset - Local DNS Mapping for Mihomo NameServer Policy (lancache)')
  378. .appendDescription(
  379. SHARED_DESCRIPTION,
  380. '',
  381. 'This ruleset is only used for mihomo\'s nameserver-policy feature, which',
  382. 'is similar to the RULE-SET referenced by sukka_local_dns_mapping.sgmodule.',
  383. 'Do not use this file in your Rule section.'
  384. );
  385. for (let i = 0, len = allDomains.length; i < len; i++) {
  386. const domain = allDomains[i];
  387. if (domain.includes('*')) {
  388. // If only *. prefix is used, we can convert it to DOMAIN-SUFFIX
  389. if (domain.startsWith('*.') && !domain.slice(2).includes('*')) {
  390. const domainSuffix = domain.slice(2);
  391. surgeOutput.addDomainSuffix(domainSuffix);
  392. mihomoOutput.addDomainSuffix(domainSuffix);
  393. continue;
  394. }
  395. surgeOutput.addDomainWildcard(domain);
  396. mihomoOutput.addDomainWildcard(domain);
  397. continue;
  398. }
  399. surgeOutput.addDomain(domain);
  400. mihomoOutput.addDomain(domain);
  401. }
  402. return Promise.all([
  403. surgeOutput.write(),
  404. mihomoOutput.write()
  405. ]);
  406. }