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

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