Add sing-box support

This commit is contained in:
SukkaW 2024-08-13 01:32:55 +08:00
parent 12665a743d
commit 75f188f1c1
16 changed files with 159 additions and 17 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ tmp*
List/ List/
Clash/ Clash/
Internal/ Internal/
sing-box/
Modules/sukka_local_dns_mapping.sgmodule Modules/sukka_local_dns_mapping.sgmodule
Modules/sukka_url_redirect.sgmodule Modules/sukka_url_redirect.sgmodule
Modules/sukka_common_always_realip.sgmodule Modules/sukka_common_always_realip.sgmodule

View File

@ -43,7 +43,8 @@ export const buildAppleCdn = task(require.main === module, __filename)(async (sp
ruleset, ruleset,
'ruleset', 'ruleset',
path.resolve(__dirname, '../List/non_ip/apple_cdn.conf'), 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( createRuleset(
span, span,
@ -54,6 +55,7 @@ export const buildAppleCdn = task(require.main === module, __filename)(async (sp
'domainset', 'domainset',
path.resolve(__dirname, '../List/domainset/apple_cdn.conf'), path.resolve(__dirname, '../List/domainset/apple_cdn.conf'),
path.resolve(__dirname, '../Clash/domainset/apple_cdn.txt'), 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') path.resolve(__dirname, '../Clash/clash_mrs_domain/apple_cdn.mrs')
) )
]); ]);

View File

@ -79,6 +79,7 @@ export const buildCdnDownloadConf = task(require.main === module, __filename)(as
'domainset', 'domainset',
path.resolve(__dirname, '../List/domainset/cdn.conf'), path.resolve(__dirname, '../List/domainset/cdn.conf'),
path.resolve(__dirname, '../Clash/domainset/cdn.txt'), path.resolve(__dirname, '../Clash/domainset/cdn.txt'),
path.resolve(__dirname, '../sing-box/domainset/cdn.json'),
path.resolve(__dirname, '../Clash/clash_mrs_domain/cdn.mrs') path.resolve(__dirname, '../Clash/clash_mrs_domain/cdn.mrs')
), ),
createRuleset( createRuleset(
@ -94,6 +95,7 @@ export const buildCdnDownloadConf = task(require.main === module, __filename)(as
'domainset', 'domainset',
path.resolve(__dirname, '../List/domainset/download.conf'), path.resolve(__dirname, '../List/domainset/download.conf'),
path.resolve(__dirname, '../Clash/domainset/download.txt'), path.resolve(__dirname, '../Clash/domainset/download.txt'),
path.resolve(__dirname, '../sing-box/domainset/download.json'),
path.resolve(__dirname, '../Clash/clash_mrs_domain/download.mrs') path.resolve(__dirname, '../Clash/clash_mrs_domain/download.mrs')
) )
]); ]);

View File

@ -6,6 +6,7 @@ import { task } from './trace';
const outputSurgeDir = path.resolve(__dirname, '../List'); const outputSurgeDir = path.resolve(__dirname, '../List');
const outputClashDir = path.resolve(__dirname, '../Clash'); const outputClashDir = path.resolve(__dirname, '../Clash');
const outputSingboxDir = path.resolve(__dirname, '../sing-box');
export const buildCloudMounterRules = task(require.main === module, __filename)(async (span) => { export const buildCloudMounterRules = task(require.main === module, __filename)(async (span) => {
// AND,((SRC-IP,192.168.1.110), (DOMAIN, example.com)) // AND,((SRC-IP,192.168.1.110), (DOMAIN, example.com))
@ -24,6 +25,7 @@ export const buildCloudMounterRules = task(require.main === module, __filename)(
results, results,
'ruleset', 'ruleset',
path.resolve(outputSurgeDir, 'non_ip', 'cloudmounter.conf'), 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')
); );
}); });

View File

@ -18,6 +18,7 @@ const MAGIC_COMMAND_DESCRIPTION = '# $ meta_description ';
const sourceDir = path.resolve(__dirname, '../Source'); const sourceDir = path.resolve(__dirname, '../Source');
const outputSurgeDir = path.resolve(__dirname, '../List'); const outputSurgeDir = path.resolve(__dirname, '../List');
const outputClashDir = path.resolve(__dirname, '../Clash'); const outputClashDir = path.resolve(__dirname, '../Clash');
const outputSingboxDir = path.resolve(__dirname, '../sing-box');
const domainsetSrcFolder = 'domainset' + path.sep; const domainsetSrcFolder = 'domainset' + path.sep;
@ -137,7 +138,8 @@ function transformDomainset(parentSpan: Span, sourcePath: string, relativePath:
deduped, deduped,
'domainset', 'domainset',
path.resolve(outputSurgeDir, relativePath), 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; description = SHARED_DESCRIPTION;
} }
const clashFileBasename = relativePath.slice(0, -path.extname(relativePath).length);
return createRuleset( return createRuleset(
span, span,
title, title,
@ -172,7 +176,8 @@ async function transformRuleset(parentSpan: Span, sourcePath: string, relativePa
lines, lines,
'ruleset', 'ruleset',
path.resolve(outputSurgeDir, relativePath), 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`)
); );
}); });
} }

View File

@ -49,7 +49,8 @@ export const buildDomesticRuleset = task(require.main === module, __filename)(as
res[0], res[0],
'ruleset', 'ruleset',
path.resolve(__dirname, '../List/non_ip/domestic.conf'), 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( createRuleset(
span, span,
@ -63,7 +64,8 @@ export const buildDomesticRuleset = task(require.main === module, __filename)(as
res[1], res[1],
'ruleset', 'ruleset',
path.resolve(__dirname, '../List/non_ip/direct.conf'), 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( createRuleset(
span, span,
@ -77,7 +79,8 @@ export const buildDomesticRuleset = task(require.main === module, __filename)(as
res[2], res[2],
'ruleset', 'ruleset',
path.resolve(__dirname, '../List/non_ip/lan.conf'), 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( compareAndWriteFile(
span, span,

View File

@ -64,6 +64,7 @@ export const buildMicrosoftCdn = task(require.main === module, __filename)(async
res, res,
'ruleset', 'ruleset',
path.resolve(__dirname, '../List/non_ip/microsoft_cdn.conf'), 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')
); );
}); });

View File

@ -192,6 +192,7 @@ export const buildRejectDomainSet = task(require.main === module, __filename)(as
'domainset', 'domainset',
path.resolve(__dirname, '../List/domainset/reject.conf'), path.resolve(__dirname, '../List/domainset/reject.conf'),
path.resolve(__dirname, '../Clash/domainset/reject.txt'), path.resolve(__dirname, '../Clash/domainset/reject.txt'),
path.resolve(__dirname, '../sing-box/domainset/reject.json'),
path.resolve(__dirname, '../Clash/clash_mrs_domain/reject.mrs') path.resolve(__dirname, '../Clash/clash_mrs_domain/reject.mrs')
), ),
createRuleset( createRuleset(
@ -213,6 +214,7 @@ export const buildRejectDomainSet = task(require.main === module, __filename)(as
'domainset', 'domainset',
path.resolve(__dirname, '../List/domainset/reject_extra.conf'), path.resolve(__dirname, '../List/domainset/reject_extra.conf'),
path.resolve(__dirname, '../Clash/domainset/reject_extra.txt'), 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') path.resolve(__dirname, '../Clash/clash_mrs_domain/reject_extra.mrs')
), ),
compareAndWriteFile( compareAndWriteFile(

View File

@ -101,6 +101,7 @@ export const buildRejectIPList = task(require.main === module, __filename)(async
result, result,
'ruleset', 'ruleset',
path.resolve(__dirname, '../List/ip/reject.conf'), 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')
); );
}); });

View File

@ -252,6 +252,7 @@ export const buildSpeedtestDomainSet = task(require.main === module, __filename)
'domainset', 'domainset',
path.resolve(__dirname, '../List/domainset/speedtest.conf'), path.resolve(__dirname, '../List/domainset/speedtest.conf'),
path.resolve(__dirname, '../Clash/domainset/speedtest.txt'), path.resolve(__dirname, '../Clash/domainset/speedtest.txt'),
path.resolve(__dirname, '../sing-box/domainset/speedtest.json'),
path.resolve(__dirname, '../Clash/clash_mrs_domain/speedtest.mrs') path.resolve(__dirname, '../Clash/clash_mrs_domain/speedtest.mrs')
); );
}); });

View File

@ -23,7 +23,8 @@ export const createRulesetForStreamService = (span: Span, fileId: string, title:
streamServices.flatMap((i) => i.rules), streamServices.flatMap((i) => i.rules),
'ruleset', 'ruleset',
path.resolve(__dirname, `../List/non_ip/${fileId}.conf`), 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 // IP
createRuleset( createRuleset(
@ -45,7 +46,8 @@ export const createRulesetForStreamService = (span: Span, fileId: string, title:
)), )),
'ruleset', 'ruleset',
path.resolve(__dirname, `../List/ip/${fileId}.conf`), 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`)
) )
])); ]));
}; };

View File

@ -53,6 +53,7 @@ export const buildTelegramCIDR = task(require.main === module, __filename)(async
results, results,
'ruleset', 'ruleset',
path.resolve(__dirname, '../List/ip/telegram.conf'), 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')
); );
}); });

View File

@ -6,6 +6,8 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { fastStringArrayJoin, writeFile } from './misc'; import { fastStringArrayJoin, writeFile } from './misc';
import { readFileByLine } from './fetch-text-by-line'; 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) { export async function compareAndWriteFile(span: Span, linesA: string[], filePath: string) {
let isEqual = true; let isEqual = true;
@ -152,14 +154,13 @@ export const createRuleset = (
parentSpan: Span, parentSpan: Span,
title: string, description: string[] | readonly string[], date: Date, content: string[], title: string, description: string[] | readonly string[], date: Date, content: string[],
type: ('ruleset' | 'domainset' | string & {}), type: ('ruleset' | 'domainset' | string & {}),
surgePath: string, clashPath: string, surgePath: string, clashPath: string, singBoxPath: string, _clashMrsPath?: string
clashMrsPath?: string
) => parentSpan.traceChild(`create ruleset: ${path.basename(surgePath, path.extname(surgePath))}`).traceAsyncFn(async (childSpan) => { ) => parentSpan.traceChild(`create ruleset: ${path.basename(surgePath, path.extname(surgePath))}`).traceAsyncFn(async (childSpan) => {
const surgeContent = withBannerArray( const surgeContent = withBannerArray(
title, description, date, title, description, date,
sortRuleSet(type === 'domainset' type === 'domainset'
? [MARK, ...content] ? [MARK, ...content]
: [`DOMAIN,${MARK}`, ...content]) : sortRuleSet([`DOMAIN,${MARK}`, ...content])
); );
const clashContent = childSpan.traceChildSync('convert incoming ruleset to clash', () => { const clashContent = childSpan.traceChildSync('convert incoming ruleset to clash', () => {
let _clashContent; let _clashContent;
@ -175,10 +176,25 @@ export const createRuleset = (
} }
return withBannerArray(title, description, date, _clashContent); 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([ await Promise.all([
compareAndWriteFile(childSpan, surgeContent, surgePath), compareAndWriteFile(childSpan, surgeContent, surgePath),
compareAndWriteFile(childSpan, clashContent, clashPath) compareAndWriteFile(childSpan, clashContent, clashPath),
compareAndWriteFile(childSpan, singboxContent, singBoxPath)
]); ]);
// if (clashMrsPath) { // if (clashMrsPath) {

94
Build/lib/singbox.ts Normal file
View File

@ -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<string, ((raw: string, type: string, value: string) => [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<string>): SingboxSourceFormat => {
const rule: SingboxHeadlessRule = Array.from(rules).reduce<SingboxHeadlessRule>((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]
};
};

View File

@ -30,6 +30,7 @@
"fast-cidr-tools": "^0.2.5", "fast-cidr-tools": "^0.2.5",
"fdir": "^6.2.0", "fdir": "^6.2.0",
"foxact": "^0.2.36", "foxact": "^0.2.36",
"json-stringify-pretty-compact": "^3.0.0",
"mnemonist": "^0.39.8", "mnemonist": "^0.39.8",
"path-scurry": "^1.11.1", "path-scurry": "^1.11.1",
"picocolors": "^1.0.1", "picocolors": "^1.0.1",

8
pnpm-lock.yaml generated
View File

@ -41,6 +41,9 @@ importers:
foxact: foxact:
specifier: ^0.2.36 specifier: ^0.2.36
version: 0.2.36 version: 0.2.36
json-stringify-pretty-compact:
specifier: ^3.0.0
version: 3.0.0
mnemonist: mnemonist:
specifier: ^0.39.8 specifier: ^0.39.8
version: 0.39.8 version: 0.39.8
@ -1101,6 +1104,9 @@ packages:
json-stable-stringify-without-jsonify@1.0.1: json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} 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: jsonc-eslint-parser@2.4.0:
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 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-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-pretty-compact@3.0.0: {}
jsonc-eslint-parser@2.4.0: jsonc-eslint-parser@2.4.0:
dependencies: dependencies:
acorn: 8.12.1 acorn: 8.12.1