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

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