Skip to content

Commit 30d4f95

Browse files
committed
feat: sing-box 支持 hysteria2 的 obfs gecko
- 为 gecko 增加 obfs-min-packet-size / obfs-max-packet-size 参数解析与校验 - 复用安全整数解析,过滤无效值并上报错误 - 当 max 超过 2048 时自动 clamp 并输出警告
1 parent fc21418 commit 30d4f95

3 files changed

Lines changed: 224 additions & 2 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.23.30",
3+
"version": "2.23.31",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",

backend/src/core/proxy-utils/producers/sing-box.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getWireGuardAddressWithCIDR, normalizePluginMuxValue } from './utils';
55
import {
66
extractPathQueryParam,
77
getSafeIntegerPathQueryParam,
8+
parseSafeIntegerValue,
89
} from '../transport-path';
910

1011
const ipVersions = {
@@ -927,6 +928,7 @@ const hysteriaParser = (proxy = {}) => {
927928
domainResolverParser(proxy, parsedProxy);
928929
return parsedProxy;
929930
};
931+
930932
const hysteria2Parser = (proxy = {}) => {
931933
const parsedProxy = {
932934
tag: proxy.name,
@@ -950,7 +952,50 @@ const hysteria2Parser = (proxy = {}) => {
950952
});
951953
if (proxy.up) parsedProxy.up_mbps = parseInt(`${proxy.up}`, 10);
952954
if (proxy.down) parsedProxy.down_mbps = parseInt(`${proxy.down}`, 10);
953-
if (proxy.obfs === 'salamander') parsedProxy.obfs.type = 'salamander';
955+
if (['salamander', 'gecko'].includes(proxy.obfs))
956+
parsedProxy.obfs.type = proxy.obfs;
957+
if (proxy.obfs === 'gecko') {
958+
const minRaw = proxy['obfs-min-packet-size'];
959+
const maxRaw = proxy['obfs-max-packet-size'];
960+
const hasMin =
961+
minRaw !== undefined && minRaw !== null && `${minRaw}` !== '';
962+
const hasMax =
963+
maxRaw !== undefined && maxRaw !== null && `${maxRaw}` !== '';
964+
if (hasMin || hasMax) {
965+
const minPacketSize = hasMin
966+
? parseSafeIntegerValue(`${minRaw}`.trim())
967+
: undefined;
968+
const rawMaxPacketSize = hasMax
969+
? parseSafeIntegerValue(`${maxRaw}`.trim())
970+
: undefined;
971+
const maxPacketSize =
972+
rawMaxPacketSize != null
973+
? Math.min(rawMaxPacketSize, 2048)
974+
: rawMaxPacketSize;
975+
const effectiveMinPacketSize = minPacketSize ?? 512;
976+
const effectiveMaxPacketSize = maxPacketSize ?? 1200;
977+
978+
if (hasMax && rawMaxPacketSize != null && rawMaxPacketSize > 2048) {
979+
$.warn(
980+
`Gecko obfs max packet size for proxy ${proxy.name} exceeds 2048, clamped to 2048: ${maxRaw}`,
981+
);
982+
}
983+
984+
if (
985+
(hasMin && (minPacketSize == null || minPacketSize <= 0)) ||
986+
(hasMax &&
987+
(rawMaxPacketSize == null || rawMaxPacketSize <= 0)) ||
988+
effectiveMaxPacketSize < effectiveMinPacketSize
989+
) {
990+
$.error(
991+
`Invalid obfs packet size for proxy ${proxy.name}: min=${minRaw} max=${maxRaw}`,
992+
);
993+
} else {
994+
if (hasMin) parsedProxy.obfs.min_packet_size = minPacketSize;
995+
if (hasMax) parsedProxy.obfs.max_packet_size = maxPacketSize;
996+
}
997+
}
998+
}
954999
if (proxy['obfs-password'])
9551000
parsedProxy.obfs.password = proxy['obfs-password'];
9561001
if (!parsedProxy.obfs.type) delete parsedProxy.obfs;

backend/src/test/proxy-producers/structured.spec.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2855,6 +2855,183 @@ describe('Proxy structured producers', function () {
28552855
expect(tailscale).to.not.have.property('detour');
28562856
});
28572857

2858+
it('emits boolean ssh_server for sing-box Tailscale endpoints', function () {
2859+
const output = loadProducedJson('sing-box', {
2860+
type: 'tailscale',
2861+
name: 'Tailscale SSH Boolean',
2862+
'ssh-server': true,
2863+
});
2864+
2865+
const tailscale = output.endpoints.find(
2866+
(endpoint) => endpoint.tag === 'Tailscale SSH Boolean',
2867+
);
2868+
2869+
expect(tailscale.ssh_server).to.equal(true);
2870+
});
2871+
2872+
it('emits object ssh_server for sing-box Tailscale endpoints', function () {
2873+
const output = loadProducedJson('sing-box', {
2874+
type: 'tailscale',
2875+
name: 'Tailscale SSH Object',
2876+
'ssh-server': {
2877+
enabled: false,
2878+
'disable-pty': true,
2879+
'disable-sftp': true,
2880+
'disable-forwarding': true,
2881+
},
2882+
});
2883+
2884+
const tailscale = output.endpoints.find(
2885+
(endpoint) => endpoint.tag === 'Tailscale SSH Object',
2886+
);
2887+
2888+
expect(tailscale.ssh_server).to.deep.equal({
2889+
enabled: false,
2890+
disable_pty: true,
2891+
disable_sftp: true,
2892+
disable_forwarding: true,
2893+
});
2894+
});
2895+
2896+
it('does not emit gecko packet sizes for sing-box when using default values', function () {
2897+
const output = loadProducedJson('sing-box', {
2898+
type: 'hysteria2',
2899+
name: 'Gecko Default Sizes',
2900+
server: 'hy2.example.com',
2901+
port: 443,
2902+
password: 'secret',
2903+
obfs: 'gecko',
2904+
'obfs-password': 'mask',
2905+
});
2906+
2907+
const hysteria2 = output.outbounds.find(
2908+
(outbound) => outbound.tag === 'Gecko Default Sizes',
2909+
);
2910+
2911+
expectSubset(hysteria2, {
2912+
type: 'hysteria2',
2913+
obfs: {
2914+
type: 'gecko',
2915+
password: 'mask',
2916+
},
2917+
});
2918+
expect(hysteria2.obfs).to.not.have.property('min_packet_size');
2919+
expect(hysteria2.obfs).to.not.have.property('max_packet_size');
2920+
});
2921+
2922+
it('emits a single gecko packet size for sing-box and relies on the other default', function () {
2923+
const minOnlyOutput = loadProducedJson('sing-box', {
2924+
type: 'hysteria2',
2925+
name: 'Gecko Min Only',
2926+
server: 'hy2.example.com',
2927+
port: 443,
2928+
password: 'secret',
2929+
obfs: 'gecko',
2930+
'obfs-password': 'mask',
2931+
'obfs-min-packet-size': 600,
2932+
});
2933+
const maxOnlyOutput = loadProducedJson('sing-box', {
2934+
type: 'hysteria2',
2935+
name: 'Gecko Max Only',
2936+
server: 'hy2.example.com',
2937+
port: 443,
2938+
password: 'secret',
2939+
obfs: 'gecko',
2940+
'obfs-password': 'mask',
2941+
'obfs-max-packet-size': 1300,
2942+
});
2943+
2944+
const minOnly = minOnlyOutput.outbounds.find(
2945+
(outbound) => outbound.tag === 'Gecko Min Only',
2946+
);
2947+
const maxOnly = maxOnlyOutput.outbounds.find(
2948+
(outbound) => outbound.tag === 'Gecko Max Only',
2949+
);
2950+
2951+
expectSubset(minOnly, {
2952+
obfs: {
2953+
type: 'gecko',
2954+
password: 'mask',
2955+
min_packet_size: 600,
2956+
},
2957+
});
2958+
expect(minOnly.obfs).to.not.have.property('max_packet_size');
2959+
2960+
expectSubset(maxOnly, {
2961+
obfs: {
2962+
type: 'gecko',
2963+
password: 'mask',
2964+
max_packet_size: 1300,
2965+
},
2966+
});
2967+
expect(maxOnly.obfs).to.not.have.property('min_packet_size');
2968+
});
2969+
2970+
it('does not emit invalid gecko packet sizes for sing-box', function () {
2971+
const { result, errors } = captureErrors(() =>
2972+
produceInternal('sing-box', [
2973+
{
2974+
type: 'hysteria2',
2975+
name: 'Gecko Invalid Decimal',
2976+
server: 'hy2.example.com',
2977+
port: 443,
2978+
password: 'secret',
2979+
obfs: 'gecko',
2980+
'obfs-password': 'mask',
2981+
'obfs-min-packet-size': '600.5',
2982+
},
2983+
{
2984+
type: 'hysteria2',
2985+
name: 'Gecko Invalid Range',
2986+
server: 'hy2.example.com',
2987+
port: 443,
2988+
password: 'secret',
2989+
obfs: 'gecko',
2990+
'obfs-password': 'mask',
2991+
'obfs-min-packet-size': 1300,
2992+
},
2993+
]),
2994+
);
2995+
2996+
expect(errors).to.have.length(2);
2997+
expect(errors[0]).to.include('Gecko Invalid Decimal');
2998+
expect(errors[1]).to.include('Gecko Invalid Range');
2999+
for (const hysteria2 of result) {
3000+
expect(hysteria2.obfs).to.deep.equal({
3001+
type: 'gecko',
3002+
password: 'mask',
3003+
});
3004+
}
3005+
});
3006+
3007+
it('clamps oversized gecko max packet size for sing-box and logs a warning', function () {
3008+
const { result, warnings } = captureWarns(() =>
3009+
produceInternal('sing-box', {
3010+
type: 'hysteria2',
3011+
name: 'Gecko Oversized Max',
3012+
server: 'hy2.example.com',
3013+
port: 443,
3014+
password: 'secret',
3015+
obfs: 'gecko',
3016+
'obfs-password': 'mask',
3017+
'obfs-min-packet-size': 1024,
3018+
'obfs-max-packet-size': 4096,
3019+
}),
3020+
);
3021+
3022+
expect(warnings).to.have.length(1);
3023+
expect(warnings[0]).to.include('Gecko Oversized Max');
3024+
expect(warnings[0]).to.include('clamped to 2048');
3025+
expectSubset(result[0], {
3026+
obfs: {
3027+
type: 'gecko',
3028+
password: 'mask',
3029+
min_packet_size: 1024,
3030+
max_packet_size: 2048,
3031+
},
3032+
});
3033+
});
3034+
28583035
it('emits WireGuard interface CIDR suffixes for Egern exports', function () {
28593036
const proxies = [
28603037
{

0 commit comments

Comments
 (0)