Skip to content

Commit 2a760ce

Browse files
committed
feat: QX/Shadowrocket 支持 SS+TLS; 内部字段 _qx_obfs_http 方便设置 QX 的几个特殊的 http obfs
1 parent cc22851 commit 2a760ce

10 files changed

Lines changed: 774 additions & 13 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.21.89",
3+
"version": "2.21.91",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"scripts": {

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { findByName } from '@/utils/database';
2323
import { produceArtifact } from '@/restful/sync';
2424
import { getFlag, removeFlag, getISO, MMDB } from '@/utils/geo';
2525
import Gist from '@/utils/gist';
26-
import { isPresent } from './producers/utils';
26+
import { isPresent, isShadowsocksOverTls } from './producers/utils';
2727
import { doh } from '@/utils/dns';
2828
import JSON5 from 'json5';
2929
import { hex_md5 } from '@/vendor/md5';
@@ -247,6 +247,13 @@ function produce(proxies, targetPlatform, type, opts = {}) {
247247
throw new Error(`Target platform: ${targetPlatform} is not supported!`);
248248
}
249249

250+
const normalizedTarget = String(targetPlatform).toLowerCase();
251+
const supportedShadowsocksOverTlsTargets = new Set([
252+
'qx',
253+
'quantumultx',
254+
'shadowrocket',
255+
]);
256+
250257
const sni_off_supported = /Surge|SurgeMac|Shadowrocket/i.test(
251258
targetPlatform,
252259
);
@@ -258,6 +265,14 @@ function produce(proxies, targetPlatform, type, opts = {}) {
258265
return false;
259266
}
260267

268+
if (
269+
isShadowsocksOverTls(proxy) &&
270+
!supportedShadowsocksOverTlsTargets.has(normalizedTarget) &&
271+
!opts['include-unsupported-proxy']
272+
) {
273+
return false;
274+
}
275+
261276
// 对于 vless 和 vmess 代理,需要额外验证 UUID
262277
if (['vless', 'vmess'].includes(proxy.type)) {
263278
const isProxyUUIDValid = isValidUUID(proxy.uuid);

backend/src/core/proxy-utils/parsers/peggy/qx.js

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const grammars = String.raw`
2020
const obfs = {};
2121
const $ = {};
2222
23+
function setQxHttpObfs(type) {
24+
// Preserve the original QX http-obfs token for round-trip output,
25+
// including the upstream "vemss-http" typo that appears in QX
26+
// examples.
27+
proxy._qx_obfs_http = type;
28+
obfs.type = "http";
29+
return type;
30+
}
31+
2332
function handleObfs() {
2433
if (obfs.type === "ws" || obfs.type === "wss") {
2534
proxy.network = "ws";
@@ -73,17 +82,20 @@ shadowsocks = "shadowsocks" equals address
7382
$set(proxy, "plugin-opts.tls", true);
7483
}
7584
} else if (obfs.type === 'over-tls') {
76-
throw new Error('ss over-tls is not supported');
85+
proxy.tls = true;
86+
if (obfs.host) {
87+
proxy.sni = obfs.host;
88+
}
7789
}
78-
if (obfs.type) {
90+
if (obfs.type && obfs.type !== 'over-tls') {
7991
$set(proxy, "plugin-opts.host", obfs.host);
8092
$set(proxy, "plugin-opts.path", obfs.path);
8193
}
8294
}
8395
}
8496
8597
vmess = "vmess" equals address
86-
(uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/reality_base64_pubkey/reality_hex_shortid/others)* {
98+
(uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs_vmess/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/reality_base64_pubkey/reality_hex_shortid/others)* {
8799
proxy.type = "vmess";
88100
proxy.cipher = proxy.cipher || "none";
89101
if (proxy.aead === false) {
@@ -95,7 +107,7 @@ vmess = "vmess" equals address
95107
}
96108
97109
vless = "vless" equals address
98-
(uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/reality_base64_pubkey/reality_hex_shortid/vless_flow/others)* {
110+
(uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs_vless/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/reality_base64_pubkey/reality_hex_shortid/vless_flow/others)* {
99111
proxy.type = "vless";
100112
proxy.cipher = proxy.cipher || "none";
101113
handleObfs();
@@ -176,9 +188,32 @@ tls_no_session_reuse = comma "tls-no-session-reuse" equals flag:bool {
176188
proxy["tls-no-session-reuse"] = flag;
177189
}
178190
179-
obfs_ss = comma "obfs" equals type:("http"/"tls"/"wss"/"ws"/"over-tls") { obfs.type = type; return type; }
191+
obfs_ss = comma "obfs" equals (
192+
type:("tls"/"wss"/"ws"/"over-tls") { obfs.type = type; return type; }
193+
/ type:("http"/"vmess-http"/"vemss-http"/"shadowsocks-http") {
194+
// QX accepts multiple http-obfs spellings for ss/vmess/vless; keep
195+
// the original token so QX output can round-trip it unchanged.
196+
return setQxHttpObfs(type);
197+
}
198+
)
180199
obfs_ssr = comma "obfs" equals type:("plain"/"http_simple"/"http_post"/"random_head"/"tls1.2_ticket_auth"/"tls1.2_ticket_fastauth") { proxy.type = "ssr"; obfs.type = type; return type; }
181200
obfs = comma "obfs" equals type:("wss"/"ws"/"over-tls"/"http") { obfs.type = type; return type; };
201+
obfs_vmess = comma "obfs" equals (
202+
type:("wss"/"ws"/"over-tls") { obfs.type = type; return type; }
203+
/ type:("http"/"vmess-http"/"vemss-http"/"shadowsocks-http") {
204+
// QX accepts multiple http-obfs spellings for ss/vmess/vless; keep
205+
// the original token so QX output can round-trip it unchanged.
206+
return setQxHttpObfs(type);
207+
}
208+
);
209+
obfs_vless = comma "obfs" equals (
210+
type:("wss"/"ws"/"over-tls") { obfs.type = type; return type; }
211+
/ type:("http"/"vmess-http"/"vemss-http"/"shadowsocks-http") {
212+
// QX accepts multiple http-obfs spellings for ss/vmess/vless; keep
213+
// the original token so QX output can round-trip it unchanged.
214+
return setQxHttpObfs(type);
215+
}
216+
);
182217
183218
obfs_host = comma "obfs-host" equals match:[^,]+ { obfs.host = match.join("").replace(/^"(.*)"$/, '$1'); }
184219
obfs_uri = comma "obfs-uri" equals uri:uri { obfs.path = uri; }

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isPresent, Result } from './utils';
1+
import { isPresent, isShadowsocksOverTls, Result } from './utils';
22

33
const targetPlatform = 'QX';
44

@@ -54,10 +54,24 @@ export default function QX_Producer() {
5454
};
5555
}
5656

57+
function getQxHttpObfs(proxy) {
58+
// QX accepts multiple http-obfs spellings for ss/vmess/vless. Preserve
59+
// the original token for round-trip output, including "vemss-http".
60+
return [
61+
'http',
62+
'vmess-http',
63+
'vemss-http',
64+
'shadowsocks-http',
65+
].includes(proxy._qx_obfs_http)
66+
? proxy._qx_obfs_http
67+
: 'http';
68+
}
69+
5770
function shadowsocks(proxy) {
5871
const result = new Result(proxy);
5972
const append = result.append.bind(result);
6073
const appendIfPresent = result.appendIfPresent.bind(result);
74+
const isSSOverTls = isShadowsocksOverTls(proxy);
6175
if (!proxy.cipher) {
6276
proxy.cipher = 'none';
6377
}
@@ -98,10 +112,23 @@ function shadowsocks(proxy) {
98112
if (needTls(proxy)) {
99113
proxy.tls = true;
100114
}
101-
if (isPresent(proxy, 'plugin')) {
115+
if (isSSOverTls) {
116+
append(`,obfs=over-tls`);
117+
if (isPresent(proxy, 'sni')) {
118+
append(`,obfs-host=${proxy.sni}`);
119+
} else {
120+
appendIfPresent(`,obfs-host=${proxy.servername}`, 'servername');
121+
}
122+
} else if (isPresent(proxy, 'plugin')) {
102123
if (proxy.plugin === 'obfs') {
103124
const opts = proxy['plugin-opts'];
104-
append(`,obfs=${opts.mode}`);
125+
if (opts.mode === 'http') {
126+
// Keep the original QX http-obfs token instead of collapsing
127+
// it back to plain "http".
128+
append(`,obfs=${getQxHttpObfs(proxy)}`);
129+
} else {
130+
append(`,obfs=${opts.mode}`);
131+
}
105132
} else if (
106133
proxy.plugin === 'v2ray-plugin' &&
107134
proxy['plugin-opts'].mode === 'websocket'
@@ -147,7 +174,9 @@ function shadowsocks(proxy) {
147174
`,tls-verification=${!proxy['skip-cert-verify']}`,
148175
'skip-cert-verify',
149176
);
150-
appendIfPresent(`,tls-host=${proxy.sni}`, 'sni');
177+
if (!isSSOverTls) {
178+
appendIfPresent(`,tls-host=${proxy.sni}`, 'sni');
179+
}
151180
}
152181

153182
// tfo
@@ -325,7 +354,7 @@ function vmess(proxy) {
325354
if (proxy.tls) append(`,obfs=wss`);
326355
else append(`,obfs=ws`);
327356
} else if (proxy.network === 'http') {
328-
append(`,obfs=http`);
357+
append(`,obfs=${getQxHttpObfs(proxy)}`);
329358
} else if (['tcp'].includes(proxy.network)) {
330359
if (proxy.tls) append(`,obfs=over-tls`);
331360
} else if (!['tcp'].includes(proxy.network)) {
@@ -431,7 +460,7 @@ function vless(proxy) {
431460
if (proxy.tls) append(`,obfs=wss`);
432461
else append(`,obfs=ws`);
433462
} else if (proxy.network === 'http') {
434-
append(`,obfs=http`);
463+
append(`,obfs=${getQxHttpObfs(proxy)}`);
435464
} else if (['tcp'].includes(proxy.network)) {
436465
if (proxy.tls) append(`,obfs=over-tls`);
437466
} else if (!['tcp'].includes(proxy.network)) {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
isPresent,
3+
isShadowsocksOverTls,
34
produceProxyListOutput,
45
} from '@/core/proxy-utils/producers/utils';
56
import $ from '@/core/app';
@@ -156,6 +157,13 @@ export default function Shadowrocket_Producer() {
156157
delete proxy['shadow-tls-sni'];
157158
delete proxy['shadow-tls-version'];
158159
}
160+
if (isShadowsocksOverTls(proxy)) {
161+
if (isPresent(proxy, 'sni')) {
162+
proxy.servername = proxy.sni;
163+
// 先不删 没有明确的规范
164+
// delete proxy.sni;
165+
}
166+
}
159167
}
160168

161169
if (

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ export function isPresent(obj, attr) {
3030
return typeof data !== 'undefined' && data !== null;
3131
}
3232

33+
export function isShadowsocksOverTls(proxy) {
34+
const normalizedNetwork =
35+
typeof proxy?.network === 'string'
36+
? proxy.network.trim().toLowerCase()
37+
: proxy?.network;
38+
return (
39+
proxy?.type === 'ss' &&
40+
proxy?.tls === true &&
41+
!isPresent(proxy, 'plugin') &&
42+
(!isPresent(proxy, 'network') || normalizedNetwork === 'tcp')
43+
);
44+
}
45+
3346
export function normalizePluginMuxValue(mux) {
3447
if (typeof mux === 'boolean') return Number(mux);
3548
if (typeof mux === 'string') {

0 commit comments

Comments
 (0)