Skip to content

Commit 5c90301

Browse files
committed
feat: 支持 Shadowrocket gost-plugin SS URI 解析,mihomo plugin mux 规范化
- 在 SS URI 解析中新增 `gost=` 参数解析,支持 address/port/route/host/path/route 的标准化映射 - `route=ws|wss|websocket` 时自动转换为 `plugin-opts.mode`,并在 wss 场景下启用 tls - 增加 mihomo 风格输出对 `plugin-opts.mux` 的布尔化处理(兼容 0/1、true/false、字符串等输入) - 补充 parser 和 producer 测试:覆盖 gost-plugin 解析、mihomo mux 归一化与保留
1 parent 7dcc0b0 commit 5c90301

6 files changed

Lines changed: 176 additions & 19 deletions

File tree

backend/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.23.16",
3+
"version": "2.23.17",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",
@@ -60,4 +60,4 @@
6060
"peggy": "^2.0.1",
6161
"prettier": "2.6.2"
6262
}
63-
}
63+
}

backend/src/core/proxy-utils/parsers/index.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ function URI_SS() {
405405
// handle obfs
406406
const pluginMatch = content.match(/[?&]plugin=([^&]+)/);
407407
const shadowTlsMatch = content.match(/[?&]shadow-tls=([^&]+)/);
408+
const gostMatch = content.match(/[?&]gost=([^&]+)/);
408409

409410
if (pluginMatch) {
410411
const pluginInfo = (
@@ -486,6 +487,33 @@ function URI_SS() {
486487
proxy.port = parseInt(port, 10);
487488
}
488489
}
490+
if (gostMatch) {
491+
const params = JSON.parse(
492+
Base64.decode(decodeURIComponent(gostMatch[1])),
493+
);
494+
const address = getIfNotBlank(params['address']);
495+
const port = getIfNotBlank(params['port']);
496+
const route = getIfNotBlank(params['route']);
497+
const normalizedRoute = route?.trim().toLowerCase();
498+
const isWebsocketRoute = ['ws', 'wss', 'websocket'].includes(
499+
normalizedRoute,
500+
);
501+
proxy.plugin = 'gost-plugin';
502+
proxy['plugin-opts'] = {
503+
mode: isWebsocketRoute ? 'websocket' : route,
504+
host: getIfNotBlank(params['host']),
505+
path: getIfNotBlank(params['path']),
506+
};
507+
if (normalizedRoute === 'wss') {
508+
proxy['plugin-opts'].tls = true;
509+
}
510+
if (address) {
511+
proxy.server = address;
512+
}
513+
if (port) {
514+
proxy.port = parseInt(port, 10);
515+
}
516+
}
489517
if (/(&|\?)uot=(1|true)/i.test(query)) {
490518
proxy['udp-over-tcp'] = true;
491519
}

backend/src/core/proxy-utils/producers/clashmeta.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
getWireGuardAddressWithCIDR,
33
isPresent,
4+
normalizePluginMuxBooleanValue,
45
produceProxyListOutput,
56
supportsShadowsocksV2rayPluginMode,
67
} from '@/core/proxy-utils/producers/utils';
@@ -261,6 +262,12 @@ export default function ClashMeta_Producer() {
261262
}
262263
}
263264

265+
if (isPresent(proxy, 'plugin-opts.mux')) {
266+
proxy['plugin-opts'].mux = normalizePluginMuxBooleanValue(
267+
proxy['plugin-opts'].mux,
268+
);
269+
}
270+
264271
if (
265272
['vmess', 'vless'].includes(proxy.type) &&
266273
proxy.network === 'http'

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ export function normalizePluginMuxValue(mux) {
5555
return mux;
5656
}
5757

58+
export function normalizePluginMuxBooleanValue(mux) {
59+
return Boolean(normalizePluginMuxValue(mux));
60+
}
61+
5862
export function supportsShadowsocksV2rayPluginMode(proxy, supportedModes) {
5963
if (proxy?.type !== 'ss' || proxy?.plugin !== 'v2ray-plugin') return true;
6064

@@ -138,16 +142,15 @@ export function getWireGuardAddressWithCIDR(proxy = {}, family = 'ipv4') {
138142
proxy[config.cidrKey],
139143
config.defaultCIDR,
140144
);
141-
return `${parsed.address}/${normalizedCIDR ?? parsed.cidr ?? config.defaultCIDR}`;
145+
return `${parsed.address}/${
146+
normalizedCIDR ?? parsed.cidr ?? config.defaultCIDR
147+
}`;
142148
}
143149

144150
export function produceProxyListOutput(list, type, opts = {}) {
145151
if (type === 'internal') return list;
146152

147-
if (
148-
opts.prettyYaml ||
149-
opts['pretty-yaml']
150-
) {
153+
if (opts.prettyYaml || opts['pretty-yaml']) {
151154
return YAML.safeDump(
152155
{
153156
proxies: list,
@@ -158,6 +161,8 @@ export function produceProxyListOutput(list, type, opts = {}) {
158161
);
159162
}
160163

161-
return 'proxies:\n' +
162-
list.map((proxy) => ' - ' + JSON.stringify(proxy) + '\n').join('');
164+
return (
165+
'proxies:\n' +
166+
list.map((proxy) => ' - ' + JSON.stringify(proxy) + '\n').join('')
167+
);
163168
}

backend/src/test/proxy-parsers/uri.spec.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,9 +287,7 @@ describe('Proxy URI parser coverage', function () {
287287
'early-data-header-name': 'Sec-WebSocket-Protocol',
288288
},
289289
});
290-
expect(proxy['ws-opts']).to.not.have.property(
291-
'v2ray-http-upgrade',
292-
);
290+
expect(proxy['ws-opts']).to.not.have.property('v2ray-http-upgrade');
293291
});
294292

295293
it('does not double-decode shadowsocks path query values before extracting early data', function () {
@@ -353,6 +351,27 @@ describe('Proxy URI parser coverage', function () {
353351
});
354352
});
355353

354+
it('parses Shadowrocket shadowsocks gost-plugin payloads', function () {
355+
const proxy = parseOne(
356+
'ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206WVRFMVpXVTRaVEV5WmpjM1ltRXpaQT09OlkySmhaVFUzT0RZdFpqZzNNQzAwTkE9PUBvcGVuYWkuY29tOjEx?gost=eyJwYXRoIjoiXC93cyIsInBvcnQiOiIxMSIsImhvc3QiOiJhIiwicm91dGUiOiJ3cyIsImFkZHJlc3MiOiJhIn0#%F0%9F%87%AF%F0%9F%87%B5%20%E6%97%A5%E6%9C%AC-A77ACD92',
357+
);
358+
359+
expectSubset(proxy, {
360+
type: 'ss',
361+
name: '🇯🇵 日本-A77ACD92',
362+
server: 'a',
363+
port: 11,
364+
cipher: '2022-blake3-aes-128-gcm',
365+
password: 'YTE1ZWU4ZTEyZjc3YmEzZA==:Y2JhZTU3ODYtZjg3MC00NA==',
366+
plugin: 'gost-plugin',
367+
'plugin-opts': {
368+
mode: 'websocket',
369+
host: 'a',
370+
path: '/ws',
371+
},
372+
});
373+
});
374+
356375
it('parses SSR URIs with protocol and obfs parameters', function () {
357376
const encoded = Base64.encode(
358377
[

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

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,7 +1557,7 @@ describe('Proxy structured producers', function () {
15571557
});
15581558
});
15591559

1560-
it('preserves numeric v2ray-plugin mux values across Clash-family YAML producers', function () {
1560+
it('preserves numeric v2ray-plugin mux values across non-Mihomo Clash-family YAML producers', function () {
15611561
const buildProxy = (name, mux) => ({
15621562
type: 'ss',
15631563
name,
@@ -1577,12 +1577,7 @@ describe('Proxy structured producers', function () {
15771577
},
15781578
});
15791579

1580-
for (const platform of [
1581-
'Clash',
1582-
'ClashMeta',
1583-
'Shadowrocket',
1584-
'Stash',
1585-
]) {
1580+
for (const platform of ['Clash', 'Shadowrocket', 'Stash']) {
15861581
const internal = produceInternal(platform, [
15871582
buildProxy(`${platform} Mux On`, 1),
15881583
buildProxy(`${platform} Mux Off`, 0),
@@ -1608,6 +1603,109 @@ describe('Proxy structured producers', function () {
16081603
}
16091604
});
16101605

1606+
it('normalizes plugin mux values to booleans for Mihomo-compatible YAML producers', function () {
1607+
const buildProxy = (name, mux) => ({
1608+
type: 'ss',
1609+
name,
1610+
server: 'ss.example.com',
1611+
port: 8388,
1612+
cipher: 'aes-128-gcm',
1613+
password: 'secret',
1614+
plugin: 'v2ray-plugin',
1615+
'plugin-opts': {
1616+
mode: 'websocket',
1617+
host: 'cdn.example.com',
1618+
path: '/socket',
1619+
tls: true,
1620+
mux,
1621+
},
1622+
});
1623+
const cases = [
1624+
['Number On', 1, true],
1625+
['Number Off', 0, false],
1626+
['Boolean On', true, true],
1627+
['Boolean Off', false, false],
1628+
['String True', ' TRUE ', true],
1629+
['String False', ' false ', false],
1630+
['String One', '1', true],
1631+
['String Zero', '0', false],
1632+
];
1633+
const proxies = cases.map(([name, mux]) =>
1634+
buildProxy(`Mihomo ${name}`, mux),
1635+
);
1636+
1637+
for (const platform of ['Mihomo', 'ClashMeta']) {
1638+
const internal = produceInternal(platform, proxies);
1639+
const external = loadProducedYaml(platform, proxies);
1640+
1641+
expect(internal, platform).to.have.length(cases.length);
1642+
expect(external.proxies, platform).to.have.length(cases.length);
1643+
1644+
cases.forEach(([name, , expected], index) => {
1645+
expect(
1646+
internal[index]['plugin-opts'].mux,
1647+
`${platform} internal ${name}`,
1648+
).to.equal(expected);
1649+
expect(
1650+
external.proxies[index]['plugin-opts'].mux,
1651+
`${platform} external ${name}`,
1652+
).to.equal(expected);
1653+
});
1654+
}
1655+
});
1656+
1657+
it('preserves Mihomo shadowsocks gost-plugin options with boolean mux', function () {
1658+
const proxy = {
1659+
type: 'ss',
1660+
name: 'Mihomo Gost Plugin',
1661+
server: 'ss.example.com',
1662+
port: 8388,
1663+
cipher: 'aes-128-gcm',
1664+
password: 'secret',
1665+
plugin: 'gost-plugin',
1666+
'plugin-opts': {
1667+
mode: 'websocket',
1668+
tls: true,
1669+
fingerprint: 'SHA256:TEST',
1670+
certificate: 'inline-test-client-cert',
1671+
'private-key': 'inline-test-client-key',
1672+
'skip-cert-verify': true,
1673+
host: 'cdn.example.com',
1674+
path: '/socket',
1675+
mux: 1,
1676+
headers: {
1677+
custom: 'value',
1678+
},
1679+
},
1680+
};
1681+
1682+
const internal = produceInternal('Mihomo', proxy);
1683+
const external = loadProducedYaml('Mihomo', proxy);
1684+
const expected = {
1685+
type: 'ss',
1686+
name: 'Mihomo Gost Plugin',
1687+
plugin: 'gost-plugin',
1688+
'plugin-opts': {
1689+
mode: 'websocket',
1690+
tls: true,
1691+
fingerprint: 'SHA256:TEST',
1692+
certificate: 'inline-test-client-cert',
1693+
'private-key': 'inline-test-client-key',
1694+
'skip-cert-verify': true,
1695+
host: 'cdn.example.com',
1696+
path: '/socket',
1697+
mux: true,
1698+
headers: {
1699+
custom: 'value',
1700+
},
1701+
},
1702+
};
1703+
1704+
expect(internal).to.have.length(1);
1705+
expectSubset(internal[0], expected);
1706+
expectSubset(external.proxies[0], expected);
1707+
});
1708+
16111709
it('keeps legacy single-line proxy output by default for Clash-style YAML producers', function () {
16121710
const proxy = {
16131711
type: 'ss',

0 commit comments

Comments
 (0)