From 75f188f1c163813790720eb8c6b93076e82eae18 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Tue, 13 Aug 2024 01:32:55 +0800 Subject: [PATCH] Add sing-box support --- .gitignore | 1 + Build/build-apple-cdn.ts | 4 +- Build/build-cdn-download-conf.ts | 2 + Build/build-cloudmounter-rules.ts | 4 +- Build/build-common.ts | 9 +- ...c-direct-lan-ruleset-dns-mapping-module.ts | 9 +- Build/build-microsoft-cdn.ts | 3 +- Build/build-reject-domainset.ts | 2 + Build/build-reject-ip-list.ts | 3 +- Build/build-speedtest-domainset.ts | 1 + Build/build-stream-service.ts | 6 +- Build/build-telegram-cidr.ts | 3 +- Build/lib/create-file.ts | 26 ++++- Build/lib/singbox.ts | 94 +++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 8 ++ 16 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 Build/lib/singbox.ts diff --git a/.gitignore b/.gitignore index 33799c30..ed64fb00 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ tmp* List/ Clash/ Internal/ +sing-box/ Modules/sukka_local_dns_mapping.sgmodule Modules/sukka_url_redirect.sgmodule Modules/sukka_common_always_realip.sgmodule diff --git a/Build/build-apple-cdn.ts b/Build/build-apple-cdn.ts index 19c9d259..4ca3b189 100644 --- a/Build/build-apple-cdn.ts +++ b/Build/build-apple-cdn.ts @@ -43,7 +43,8 @@ export const buildAppleCdn = task(require.main === module, __filename)(async (sp ruleset, 'ruleset', path.resolve(__dirname, '../List/non_ip/apple_cdn.conf'), - path.resolve(__dirname, '../Clash/non_ip/apple_cdn.txt') + path.resolve(__dirname, '../Clash/non_ip/apple_cdn.txt'), + path.resolve(__dirname, '../sing-box/non_ip/apple_cdn.json') ), createRuleset( span, @@ -54,6 +55,7 @@ export const buildAppleCdn = task(require.main === module, __filename)(async (sp 'domainset', path.resolve(__dirname, '../List/domainset/apple_cdn.conf'), path.resolve(__dirname, '../Clash/domainset/apple_cdn.txt'), + path.resolve(__dirname, '../sing-box/domainset/apple_cdn.json'), path.resolve(__dirname, '../Clash/clash_mrs_domain/apple_cdn.mrs') ) ]); diff --git a/Build/build-cdn-download-conf.ts b/Build/build-cdn-download-conf.ts index 8c829acd..ee4c2ff1 100644 --- a/Build/build-cdn-download-conf.ts +++ b/Build/build-cdn-download-conf.ts @@ -79,6 +79,7 @@ export const buildCdnDownloadConf = task(require.main === module, __filename)(as 'domainset', path.resolve(__dirname, '../List/domainset/cdn.conf'), path.resolve(__dirname, '../Clash/domainset/cdn.txt'), + path.resolve(__dirname, '../sing-box/domainset/cdn.json'), path.resolve(__dirname, '../Clash/clash_mrs_domain/cdn.mrs') ), createRuleset( @@ -94,6 +95,7 @@ export const buildCdnDownloadConf = task(require.main === module, __filename)(as 'domainset', path.resolve(__dirname, '../List/domainset/download.conf'), path.resolve(__dirname, '../Clash/domainset/download.txt'), + path.resolve(__dirname, '../sing-box/domainset/download.json'), path.resolve(__dirname, '../Clash/clash_mrs_domain/download.mrs') ) ]); diff --git a/Build/build-cloudmounter-rules.ts b/Build/build-cloudmounter-rules.ts index 6eff40ef..ecdc6908 100644 --- a/Build/build-cloudmounter-rules.ts +++ b/Build/build-cloudmounter-rules.ts @@ -6,6 +6,7 @@ import { task } from './trace'; const outputSurgeDir = path.resolve(__dirname, '../List'); const outputClashDir = path.resolve(__dirname, '../Clash'); +const outputSingboxDir = path.resolve(__dirname, '../sing-box'); export const buildCloudMounterRules = task(require.main === module, __filename)(async (span) => { // AND,((SRC-IP,192.168.1.110), (DOMAIN, example.com)) @@ -24,6 +25,7 @@ export const buildCloudMounterRules = task(require.main === module, __filename)( results, 'ruleset', path.resolve(outputSurgeDir, 'non_ip', 'cloudmounter.conf'), - path.resolve(outputClashDir, 'non_ip', 'cloudmounter.txt') + path.resolve(outputClashDir, 'non_ip', 'cloudmounter.txt'), + path.resolve(outputSingboxDir, 'non_ip', 'cloudmounter.json') ); }); diff --git a/Build/build-common.ts b/Build/build-common.ts index aa8d6b89..f3b137e0 100644 --- a/Build/build-common.ts +++ b/Build/build-common.ts @@ -18,6 +18,7 @@ const MAGIC_COMMAND_DESCRIPTION = '# $ meta_description '; const sourceDir = path.resolve(__dirname, '../Source'); const outputSurgeDir = path.resolve(__dirname, '../List'); const outputClashDir = path.resolve(__dirname, '../Clash'); +const outputSingboxDir = path.resolve(__dirname, '../sing-box'); const domainsetSrcFolder = 'domainset' + path.sep; @@ -137,7 +138,8 @@ function transformDomainset(parentSpan: Span, sourcePath: string, relativePath: deduped, 'domainset', path.resolve(outputSurgeDir, relativePath), - path.resolve(outputClashDir, `${clashFileBasename}.txt`) + path.resolve(outputClashDir, `${clashFileBasename}.txt`), + path.resolve(outputSingboxDir, `${clashFileBasename}.json`) ); } ); @@ -164,6 +166,8 @@ async function transformRuleset(parentSpan: Span, sourcePath: string, relativePa description = SHARED_DESCRIPTION; } + const clashFileBasename = relativePath.slice(0, -path.extname(relativePath).length); + return createRuleset( span, title, @@ -172,7 +176,8 @@ async function transformRuleset(parentSpan: Span, sourcePath: string, relativePa lines, 'ruleset', path.resolve(outputSurgeDir, relativePath), - path.resolve(outputClashDir, `${relativePath.slice(0, -path.extname(relativePath).length)}.txt`) + path.resolve(outputClashDir, `${clashFileBasename}.txt`), + path.resolve(outputSingboxDir, `${clashFileBasename}.json`) ); }); } diff --git a/Build/build-domestic-direct-lan-ruleset-dns-mapping-module.ts b/Build/build-domestic-direct-lan-ruleset-dns-mapping-module.ts index aa7fce23..bb6332ef 100644 --- a/Build/build-domestic-direct-lan-ruleset-dns-mapping-module.ts +++ b/Build/build-domestic-direct-lan-ruleset-dns-mapping-module.ts @@ -49,7 +49,8 @@ export const buildDomesticRuleset = task(require.main === module, __filename)(as res[0], 'ruleset', path.resolve(__dirname, '../List/non_ip/domestic.conf'), - path.resolve(__dirname, '../Clash/non_ip/domestic.txt') + path.resolve(__dirname, '../Clash/non_ip/domestic.txt'), + path.resolve(__dirname, '../sing-box/non_ip/domestic.json') ), createRuleset( span, @@ -63,7 +64,8 @@ export const buildDomesticRuleset = task(require.main === module, __filename)(as res[1], 'ruleset', path.resolve(__dirname, '../List/non_ip/direct.conf'), - path.resolve(__dirname, '../Clash/non_ip/direct.txt') + path.resolve(__dirname, '../Clash/non_ip/direct.txt'), + path.resolve(__dirname, '../sing-box/non_ip/direct.json') ), createRuleset( span, @@ -77,7 +79,8 @@ export const buildDomesticRuleset = task(require.main === module, __filename)(as res[2], 'ruleset', path.resolve(__dirname, '../List/non_ip/lan.conf'), - path.resolve(__dirname, '../Clash/non_ip/lan.txt') + path.resolve(__dirname, '../Clash/non_ip/lan.txt'), + path.resolve(__dirname, '../sing-box/non_ip/lan.json') ), compareAndWriteFile( span, diff --git a/Build/build-microsoft-cdn.ts b/Build/build-microsoft-cdn.ts index 1fc3a25b..827154fe 100644 --- a/Build/build-microsoft-cdn.ts +++ b/Build/build-microsoft-cdn.ts @@ -64,6 +64,7 @@ export const buildMicrosoftCdn = task(require.main === module, __filename)(async res, 'ruleset', path.resolve(__dirname, '../List/non_ip/microsoft_cdn.conf'), - path.resolve(__dirname, '../Clash/non_ip/microsoft_cdn.txt') + path.resolve(__dirname, '../Clash/non_ip/microsoft_cdn.txt'), + path.resolve(__dirname, '../sing-box/non_ip/microsoft_cdn.json') ); }); diff --git a/Build/build-reject-domainset.ts b/Build/build-reject-domainset.ts index e2e399b4..02234dea 100644 --- a/Build/build-reject-domainset.ts +++ b/Build/build-reject-domainset.ts @@ -192,6 +192,7 @@ export const buildRejectDomainSet = task(require.main === module, __filename)(as 'domainset', path.resolve(__dirname, '../List/domainset/reject.conf'), path.resolve(__dirname, '../Clash/domainset/reject.txt'), + path.resolve(__dirname, '../sing-box/domainset/reject.json'), path.resolve(__dirname, '../Clash/clash_mrs_domain/reject.mrs') ), createRuleset( @@ -213,6 +214,7 @@ export const buildRejectDomainSet = task(require.main === module, __filename)(as 'domainset', path.resolve(__dirname, '../List/domainset/reject_extra.conf'), path.resolve(__dirname, '../Clash/domainset/reject_extra.txt'), + path.resolve(__dirname, '../sing-box/domainset/reject_extra.json'), path.resolve(__dirname, '../Clash/clash_mrs_domain/reject_extra.mrs') ), compareAndWriteFile( diff --git a/Build/build-reject-ip-list.ts b/Build/build-reject-ip-list.ts index ba25c9f3..67fbe61a 100644 --- a/Build/build-reject-ip-list.ts +++ b/Build/build-reject-ip-list.ts @@ -101,6 +101,7 @@ export const buildRejectIPList = task(require.main === module, __filename)(async result, 'ruleset', path.resolve(__dirname, '../List/ip/reject.conf'), - path.resolve(__dirname, '../Clash/ip/reject.txt') + path.resolve(__dirname, '../Clash/ip/reject.txt'), + path.resolve(__dirname, '../sing-box/ip/reject.json') ); }); diff --git a/Build/build-speedtest-domainset.ts b/Build/build-speedtest-domainset.ts index a1664316..4c54c474 100644 --- a/Build/build-speedtest-domainset.ts +++ b/Build/build-speedtest-domainset.ts @@ -252,6 +252,7 @@ export const buildSpeedtestDomainSet = task(require.main === module, __filename) 'domainset', path.resolve(__dirname, '../List/domainset/speedtest.conf'), path.resolve(__dirname, '../Clash/domainset/speedtest.txt'), + path.resolve(__dirname, '../sing-box/domainset/speedtest.json'), path.resolve(__dirname, '../Clash/clash_mrs_domain/speedtest.mrs') ); }); diff --git a/Build/build-stream-service.ts b/Build/build-stream-service.ts index 80c45c7c..28e6b1dd 100644 --- a/Build/build-stream-service.ts +++ b/Build/build-stream-service.ts @@ -23,7 +23,8 @@ export const createRulesetForStreamService = (span: Span, fileId: string, title: streamServices.flatMap((i) => i.rules), 'ruleset', path.resolve(__dirname, `../List/non_ip/${fileId}.conf`), - path.resolve(__dirname, `../Clash/non_ip/${fileId}.txt`) + path.resolve(__dirname, `../Clash/non_ip/${fileId}.txt`), + path.resolve(__dirname, `../sing-box/non_ip/${fileId}.json`) ), // IP createRuleset( @@ -45,7 +46,8 @@ export const createRulesetForStreamService = (span: Span, fileId: string, title: )), 'ruleset', path.resolve(__dirname, `../List/ip/${fileId}.conf`), - path.resolve(__dirname, `../Clash/ip/${fileId}.txt`) + path.resolve(__dirname, `../Clash/ip/${fileId}.txt`), + path.resolve(__dirname, `../sing-box/ip/${fileId}.json`) ) ])); }; diff --git a/Build/build-telegram-cidr.ts b/Build/build-telegram-cidr.ts index 31aa11fd..437f852c 100644 --- a/Build/build-telegram-cidr.ts +++ b/Build/build-telegram-cidr.ts @@ -53,6 +53,7 @@ export const buildTelegramCIDR = task(require.main === module, __filename)(async results, 'ruleset', path.resolve(__dirname, '../List/ip/telegram.conf'), - path.resolve(__dirname, '../Clash/ip/telegram.txt') + path.resolve(__dirname, '../Clash/ip/telegram.txt'), + path.resolve(__dirname, '../sing-box/ip/telegram.json') ); }); diff --git a/Build/lib/create-file.ts b/Build/lib/create-file.ts index 2201d966..72a98be8 100644 --- a/Build/lib/create-file.ts +++ b/Build/lib/create-file.ts @@ -6,6 +6,8 @@ import path from 'path'; import fs from 'fs'; import { fastStringArrayJoin, writeFile } from './misc'; import { readFileByLine } from './fetch-text-by-line'; +import stringify from 'json-stringify-pretty-compact'; +import { surgeDomainsetToSingbox, surgeRulesetToSingbox } from './singbox'; export async function compareAndWriteFile(span: Span, linesA: string[], filePath: string) { let isEqual = true; @@ -152,14 +154,13 @@ export const createRuleset = ( parentSpan: Span, title: string, description: string[] | readonly string[], date: Date, content: string[], type: ('ruleset' | 'domainset' | string & {}), - surgePath: string, clashPath: string, - clashMrsPath?: string + surgePath: string, clashPath: string, singBoxPath: string, _clashMrsPath?: string ) => parentSpan.traceChild(`create ruleset: ${path.basename(surgePath, path.extname(surgePath))}`).traceAsyncFn(async (childSpan) => { const surgeContent = withBannerArray( title, description, date, - sortRuleSet(type === 'domainset' + type === 'domainset' ? [MARK, ...content] - : [`DOMAIN,${MARK}`, ...content]) + : sortRuleSet([`DOMAIN,${MARK}`, ...content]) ); const clashContent = childSpan.traceChildSync('convert incoming ruleset to clash', () => { let _clashContent; @@ -175,10 +176,25 @@ export const createRuleset = ( } return withBannerArray(title, description, date, _clashContent); }); + const singboxContent = childSpan.traceChildSync('convert incoming ruleset to singbox', () => { + let _singBoxContent; + switch (type) { + case 'domainset': + _singBoxContent = surgeDomainsetToSingbox([MARK, ...content]); + break; + case 'ruleset': + _singBoxContent = surgeRulesetToSingbox([`DOMAIN,${MARK}`, ...content]); + break; + default: + throw new TypeError(`Unknown type: ${type}`); + } + return stringify(_singBoxContent).split('\n'); + }); await Promise.all([ compareAndWriteFile(childSpan, surgeContent, surgePath), - compareAndWriteFile(childSpan, clashContent, clashPath) + compareAndWriteFile(childSpan, clashContent, clashPath), + compareAndWriteFile(childSpan, singboxContent, singBoxPath) ]); // if (clashMrsPath) { diff --git a/Build/lib/singbox.ts b/Build/lib/singbox.ts new file mode 100644 index 00000000..1492d695 --- /dev/null +++ b/Build/lib/singbox.ts @@ -0,0 +1,94 @@ +import picocolors from 'picocolors'; + +const unsupported = Symbol('unsupported'); + +// https://sing-box.sagernet.org/configuration/rule-set/source-format/ +const PROCESSOR: Record [key: keyof SingboxHeadlessRule, value: string]) | typeof unsupported> = { + DOMAIN: (_1, _2, value) => ['domain', value], + 'DOMAIN-SUFFIX': (_1, _2, value) => ['domain_suffix', value], + 'DOMAIN-KEYWORD': (_1, _2, value) => ['domain_keyword', value], + GEOIP: unsupported, + 'IP-CIDR': (_1, _2, value) => ['ip_cidr', value.endsWith(',no-resolve') ? value.slice(0, -11) : value], + 'IP-CIDR6': (_1, _2, value) => ['ip_cidr', value.endsWith(',no-resolve') ? value.slice(0, -11) : value], + 'IP-ASN': unsupported, + 'SRC-IP-CIDR': (_1, _2, value) => ['source_ip_cidr', value.endsWith(',no-resolve') ? value.slice(0, -11) : value], + 'SRC-PORT': (_1, _2, value) => ['source_port', value], + 'DST-PORT': (_1, _2, value) => ['port', value], + 'PROCESS-NAME': (_1, _2, value) => ['process_name', value], + 'PROCESS-PATH': (_1, _2, value) => ['process_path', value], + 'DEST-PORT': (_1, _2, value) => ['port', value], + 'IN-PORT': (_1, _2, value) => ['source_port', value], + 'URL-REGEX': unsupported, + 'USER-AGENT': unsupported +}; + +interface SingboxHeadlessRule { + domain?: string[], + domain_suffix?: string[], + domain_keyword?: string[], + domain_regex?: string[], + source_ip_cidr?: string[], + ip_cidr?: string[], + source_port?: string[], + source_port_range?: string[], + port?: string[], + port_range?: string[], + process_name?: string[], + process_path?: string[] +} + +interface SingboxSourceFormat { + version: 2 | number & {}, + rules: SingboxHeadlessRule[] +} + +export const surgeRulesetToSingbox = (rules: string[] | Set): SingboxSourceFormat => { + const rule: SingboxHeadlessRule = Array.from(rules).reduce((acc, cur) => { + let buf = ''; + let type = ''; + let i = 0; + for (const len = cur.length; i < len; i++) { + if (cur[i] === ',') { + type = buf; + break; + } + buf += cur[i]; + } + if (type === '') { + return acc; + } + const value = cur.slice(i + 1); + if (type in PROCESSOR) { + const proc = PROCESSOR[type]; + if (proc !== unsupported) { + const [k, v] = proc(cur, type, value); + acc[k] ||= []; + acc[k].push(v); + } + } else { + console.log(picocolors.yellow(`[sing-box] unknown rule type: ${type}`), cur); + } + return acc; + }, {}); + + return { + version: 2, + rules: [rule] + }; +}; + +export const surgeDomainsetToSingbox = (domainset: string[]) => { + const rule = domainset.reduce((acc, cur) => { + if (cur[0] === '.') { + acc.domain_suffix.push(cur.slice(1)); + } else { + acc.domain.push(cur); + } + return acc; + }, { domain: [] as string[], domain_suffix: [] as string[] } satisfies SingboxHeadlessRule); + + return { + version: 2, + rules: [rule] + }; +}; diff --git a/package.json b/package.json index 5bc37cee..62308c3d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "fast-cidr-tools": "^0.2.5", "fdir": "^6.2.0", "foxact": "^0.2.36", + "json-stringify-pretty-compact": "^3.0.0", "mnemonist": "^0.39.8", "path-scurry": "^1.11.1", "picocolors": "^1.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ae6d945..33972510 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: foxact: specifier: ^0.2.36 version: 0.2.36 + json-stringify-pretty-compact: + specifier: ^3.0.0 + version: 3.0.0 mnemonist: specifier: ^0.39.8 version: 0.39.8 @@ -1101,6 +1104,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-pretty-compact@3.0.0: + resolution: {integrity: sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==} + jsonc-eslint-parser@2.4.0: resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2618,6 +2624,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-pretty-compact@3.0.0: {} + jsonc-eslint-parser@2.4.0: dependencies: acorn: 8.12.1