Skip to content

Commit 27e7fa3

Browse files
committed
feat: Surge 支持 client-cert
1 parent f069fbb commit 27e7fa3

10 files changed

Lines changed: 125 additions & 44 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.11",
3+
"version": "2.23.13",
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/parsers/peggy/surge.js

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ shadowsocks = tag equals "ss" address (method/passwordk/obfs/obfs_host/obfs_uri/
326326
}
327327
handleShadowTLS();
328328
}
329-
vmess = tag equals "vmess" address (vmess_uuid/vmess_aead/ws/ws_path/ws_headers/method/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls/sni/tls_fingerprint/tls_verification/fast_open/tfo/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
329+
vmess = tag equals "vmess" address (vmess_uuid/vmess_aead/ws/ws_path/ws_headers/method/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls/sni/tls_fingerprint/tls_verification/client_cert/fast_open/tfo/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
330330
proxy.type = "vmess";
331331
proxy.cipher = proxy.cipher || "none";
332332
// Surfboard 与 Surge 默认不一致, 不管 Surfboard https://getsurfboard.com/docs/profile-format/proxy/external-proxy/vmess
@@ -338,17 +338,17 @@ vmess = tag equals "vmess" address (vmess_uuid/vmess_aead/ws/ws_path/ws_headers/
338338
handleWebsocket();
339339
handleShadowTLS();
340340
}
341-
trojan = tag equals "trojan" address (passwordk/ws/ws_path/ws_headers/tls/sni/tls_fingerprint/tls_verification/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/tfo/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
341+
trojan = tag equals "trojan" address (passwordk/ws/ws_path/ws_headers/tls/sni/tls_fingerprint/tls_verification/client_cert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/tfo/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
342342
proxy.type = "trojan";
343343
handleWebsocket();
344344
handleShadowTLS();
345345
}
346-
https = tag equals "https" address (username password)? (usernamek passwordk)? (headers/sni/tls_fingerprint/tls_verification/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/tfo/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
346+
https = tag equals "https" address (username password)? (usernamek passwordk)? (headers/sni/tls_fingerprint/tls_verification/client_cert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/tfo/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
347347
proxy.type = "http";
348348
proxy.tls = true;
349349
handleShadowTLS();
350350
}
351-
h2_connect = tag equals "h2-connect" address (username password)? (usernamek passwordk)? (headers/sni/tls_fingerprint/tls_verification/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/tfo/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
351+
h2_connect = tag equals "h2-connect" address (username password)? (usernamek passwordk)? (headers/sni/tls_fingerprint/tls_verification/client_cert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/tfo/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
352352
proxy.type = "h2-connect";
353353
proxy.tls = true;
354354
handleShadowTLS();
@@ -371,11 +371,11 @@ snell = tag equals "snell" address (snell_version/snell_psk/obfs/obfs_host/obfs_
371371
}
372372
handleShadowTLS();
373373
}
374-
tuic = tag equals "tuic" address (alpn/token/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/sni/fast_open/tfo/ecn/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/port_hopping_interval/others)* {
374+
tuic = tag equals "tuic" address (alpn/token/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/client_cert/sni/fast_open/tfo/ecn/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/port_hopping_interval/others)* {
375375
proxy.type = "tuic";
376376
handleShadowTLS();
377377
}
378-
tuic_v5 = tag equals "tuic-v5" address (alpn/passwordk/uuidk/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/sni/fast_open/tfo/ecn/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/port_hopping_interval/others)* {
378+
tuic_v5 = tag equals "tuic-v5" address (alpn/passwordk/uuidk/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/client_cert/sni/fast_open/tfo/ecn/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/port_hopping_interval/others)* {
379379
proxy.type = "tuic";
380380
proxy.version = 5;
381381
handleShadowTLS();
@@ -384,24 +384,24 @@ wireguard = tag equals "wireguard" (section_name/no_error_alert/ip_version/under
384384
proxy.type = "wireguard-surge";
385385
handleShadowTLS();
386386
}
387-
hysteria2 = tag equals "hysteria2" address (no_error_alert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/sni/tls_verification/passwordk/tls_fingerprint/download_bandwidth/ecn/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/port_hopping_interval/salamander_password/others)* {
387+
hysteria2 = tag equals "hysteria2" address (no_error_alert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/sni/tls_verification/client_cert/passwordk/tls_fingerprint/download_bandwidth/ecn/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/port_hopping_interval/salamander_password/others)* {
388388
proxy.type = "hysteria2";
389389
handleShadowTLS();
390390
}
391391
socks5 = tag equals "socks5" address (username password)? (usernamek passwordk)? (udp_relay/no_error_alert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/fast_open/tfo/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
392392
proxy.type = "socks5";
393393
handleShadowTLS();
394394
}
395-
socks5_tls = tag equals "socks5-tls" address (username password)? (usernamek passwordk)? (udp_relay/no_error_alert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/sni/tls_fingerprint/tls_verification/fast_open/tfo/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
395+
socks5_tls = tag equals "socks5-tls" address (username password)? (usernamek passwordk)? (udp_relay/no_error_alert/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/sni/tls_fingerprint/tls_verification/client_cert/fast_open/tfo/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
396396
proxy.type = "socks5";
397397
proxy.tls = true;
398398
handleShadowTLS();
399399
}
400-
anytls = tag equals "anytls" address (passwordk/reuse/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/sni/fast_open/tfo/block_quic/others)* {
400+
anytls = tag equals "anytls" address (passwordk/reuse/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/client_cert/sni/fast_open/tfo/block_quic/others)* {
401401
proxy.type = "anytls";
402402
proxy.tls = true;
403403
}
404-
trust_tunnel = tag equals "trust-tunnel" address (usernamek/passwordk/headers/reuse/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/sni/fast_open/tfo/block_quic/others)* {
404+
trust_tunnel = tag equals "trust-tunnel" address (usernamek/passwordk/headers/reuse/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls_fingerprint/tls_verification/client_cert/sni/fast_open/tfo/block_quic/others)* {
405405
proxy.type = "trusttunnel";
406406
proxy.tls = true;
407407
}
@@ -480,6 +480,7 @@ sni = comma "sni" equals match:[^,]+ {
480480
}
481481
tls_verification = comma "skip-cert-verify" equals flag:bool { proxy["skip-cert-verify"] = flag; }
482482
tls_fingerprint = comma "server-cert-fingerprint-sha256" equals tls_fingerprint:$[^,]+ { proxy["tls-fingerprint"] = tls_fingerprint.trim(); }
483+
client_cert = comma "client-cert" equals match:[^,]+ { proxy["keystore-client-cert"] = stripQuotes(match.join("")); }
483484
484485
snell_psk = comma "psk" equals match:[^,]+ { proxy.psk = match.join(""); }
485486
snell_version = comma "version" equals match:$[0-9]+ { proxy.version = parseInt(match.trim()); }
@@ -537,7 +538,7 @@ interface = comma "interface" equals match:[^,]+ { proxy.interface = match.join(
537538
allow_other_interface = comma "allow-other-interface" equals flag:bool { proxy["allow-other-interface"] = flag; }
538539
hybrid = comma "hybrid" equals flag:bool { proxy.hybrid = flag; }
539540
idle_timeout = comma "idle-timeout" equals match:$[0-9]+ { proxy["idle-timeout"] = parseInt(match.trim()); }
540-
private_key = comma "private-key" equals match:[^,]+ { proxy["keystore-private-key"] = match.join("").replace(/^"(.*)"$/, '$1'); }
541+
private_key = comma "private-key" equals match:[^,]+ { proxy["keystore-private-key"] = stripQuotes(match.join("")); }
541542
server_fingerprint = comma "server-fingerprint" equals match:[^,]+ { proxy["server-fingerprint"] = match.join("").replace(/^"(.*)"$/, '$1'); }
542543
block_quic = comma "block-quic" equals match:[^,]+ { proxy["block-quic"] = match.join(""); }
543544
udp_port = comma "udp-port" equals match:$[0-9]+ { proxy["udp-port"] = parseInt(match.trim()); }

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,51 @@ const ipVersions = {
2323
'ipv6-prefer': 'prefer-v6',
2424
};
2525

26+
function stripSurgeQuotes(value) {
27+
if (typeof value !== 'string') return value;
28+
29+
const trimmed = value.trim();
30+
const quote = trimmed[0];
31+
if (
32+
(quote === '"' || quote === "'") &&
33+
trimmed[trimmed.length - 1] === quote
34+
) {
35+
return trimmed.slice(1, -1).replace(/\\(["'\\])/g, '$1');
36+
}
37+
38+
return trimmed.replace(/\\(["'\\])/g, '$1');
39+
}
40+
41+
function quoteSurgeValue(value) {
42+
return `"${String(stripSurgeQuotes(value))
43+
.replace(/\\/g, '\\\\')
44+
.replace(/"/g, '\\"')}"`;
45+
}
46+
47+
function appendClientCert(result, proxy) {
48+
const clientCert = isPresent(proxy, 'keystore-client-cert')
49+
? proxy['keystore-client-cert']
50+
: proxy['client-cert'];
51+
if (
52+
isPresent(proxy, 'keystore-client-cert') ||
53+
isPresent(proxy, 'client-cert')
54+
) {
55+
result.append(`,client-cert=${quoteSurgeValue(clientCert)}`);
56+
}
57+
}
58+
59+
function appendSshPrivateKey(result, proxy) {
60+
const privateKey = isPresent(proxy, 'keystore-private-key')
61+
? proxy['keystore-private-key']
62+
: proxy['private-key'];
63+
if (
64+
isPresent(proxy, 'keystore-private-key') ||
65+
isPresent(proxy, 'private-key')
66+
) {
67+
result.append(`,private-key=${quoteSurgeValue(privateKey)}`);
68+
}
69+
}
70+
2671
export default function Surge_Producer() {
2772
const produce = (proxy, type, opts = {}) => {
2873
if (
@@ -264,6 +309,7 @@ function trojan(proxy) {
264309
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
265310
'skip-cert-verify',
266311
);
312+
appendClientCert(result, proxy);
267313

268314
// tfo
269315
result.appendIfPresent(`,tfo=${proxy.tfo}`, 'tfo');
@@ -341,6 +387,7 @@ function anytls(proxy) {
341387
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
342388
'skip-cert-verify',
343389
);
390+
appendClientCert(result, proxy);
344391

345392
// tfo
346393
result.appendIfPresent(`,tfo=${proxy.tfo}`, 'tfo');
@@ -408,6 +455,7 @@ function trusttunnel(proxy) {
408455
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
409456
'skip-cert-verify',
410457
);
458+
appendClientCert(result, proxy);
411459

412460
// tfo
413461
result.appendIfPresent(`,tfo=${proxy.tfo}`, 'tfo');
@@ -472,6 +520,7 @@ function h2Connect(proxy) {
472520
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
473521
'skip-cert-verify',
474522
);
523+
appendClientCert(result, proxy);
475524

476525
if (proxy.tfo) {
477526
$.info(`Option tfo is not supported by Surge, thus omitted`);
@@ -556,6 +605,7 @@ function vmess(proxy, includeUnsupportedProxy) {
556605
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
557606
'skip-cert-verify',
558607
);
608+
appendClientCert(result, proxy);
559609

560610
// tfo
561611
result.appendIfPresent(`,tfo=${proxy.tfo}`, 'tfo');
@@ -617,10 +667,7 @@ function ssh(proxy) {
617667

618668
// https://manual.nssurge.com/policy/ssh.html
619669
// 需配合 Keystore
620-
result.appendIfPresent(
621-
`,private-key=${proxy['keystore-private-key']}`,
622-
'keystore-private-key',
623-
);
670+
appendSshPrivateKey(result, proxy);
624671
result.appendIfPresent(
625672
`,idle-timeout=${proxy['idle-timeout']}`,
626673
'idle-timeout',
@@ -702,6 +749,7 @@ function http(proxy) {
702749
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
703750
'skip-cert-verify',
704751
);
752+
appendClientCert(result, proxy);
705753

706754
// tfo
707755
result.appendIfPresent(`,tfo=${proxy.tfo}`, 'tfo');
@@ -830,6 +878,7 @@ function socks5(proxy) {
830878
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
831879
'skip-cert-verify',
832880
);
881+
appendClientCert(result, proxy);
833882

834883
// tfo
835884
if (proxy.tfo) {
@@ -1029,6 +1078,7 @@ function tuic(proxy) {
10291078
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
10301079
'skip-cert-verify',
10311080
);
1081+
appendClientCert(result, proxy);
10321082

10331083
// tls fingerprint
10341084
result.appendIfPresent(
@@ -1304,6 +1354,7 @@ function hysteria2(proxy) {
13041354
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
13051355
'skip-cert-verify',
13061356
);
1357+
appendClientCert(result, proxy);
13071358
result.appendIfPresent(
13081359
`,server-cert-fingerprint-sha256=${proxy['tls-fingerprint']}`,
13091360
'tls-fingerprint',

backend/src/products/cron-sync-artifacts-eligibility.js

Lines changed: 0 additions & 3 deletions
This file was deleted.

backend/src/products/cron-sync-artifacts.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import $ from '@/core/app';
99
import { produceArtifact, uploadArtifactBatches } from '@/restful/sync';
1010
import { findByName } from '@/utils/database';
11-
import { hasCronArtifactSyncCredentials } from '@/products/cron-sync-artifacts-eligibility';
11+
import { hasGistSyncCredentials } from '@/utils/gist';
1212

1313
!(async function () {
1414
let arg;
@@ -36,7 +36,7 @@ import { hasCronArtifactSyncCredentials } from '@/products/cron-sync-artifacts-e
3636
} else {
3737
const settings = $.read(SETTINGS_KEY);
3838
// if GitHub token is not configured
39-
if (!hasCronArtifactSyncCredentials(settings)) return;
39+
if (!hasGistSyncCredentials(settings)) return;
4040

4141
const artifacts = $.read(ARTIFACTS_KEY);
4242
if (!artifacts || artifacts.length === 0) return;

backend/src/test/products/cron-sync-artifacts.spec.js

Lines changed: 0 additions & 23 deletions
This file was deleted.

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,36 @@ describe('Proxy text producers', function () {
453453
);
454454
});
455455

456+
it('quotes Surge SSH private-key and TLS client-cert keystore values', function () {
457+
const output = ProxyUtils.produce(
458+
[
459+
{
460+
type: 'ssh',
461+
name: 'Surge SSH Key',
462+
server: 'ssh.example.com',
463+
port: 22,
464+
username: 'user',
465+
'keystore-private-key': "'ssh-key'",
466+
},
467+
{
468+
type: 'http',
469+
name: 'Surge HTTPS Client Cert',
470+
server: 'https.example.com',
471+
port: 443,
472+
tls: true,
473+
'keystore-client-cert': "'client-cert'",
474+
},
475+
],
476+
'Surge',
477+
'external',
478+
);
479+
480+
expect(output.split('\n')).to.deep.equal([
481+
'Surge SSH Key=ssh,ssh.example.com,22,username="user",private-key="ssh-key"',
482+
'Surge HTTPS Client Cert=https,https.example.com,443,client-cert="client-cert"',
483+
]);
484+
});
485+
456486
it('produces Surge root headers for HTTP, HTTPS, HTTP/2 CONNECT, and TrustTunnel', function () {
457487
const output = ProxyUtils.produce(
458488
[

backend/src/test/utils/gist.spec.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SETTINGS_KEY } from '@/constants';
66
import Gist, {
77
describeGistApiErrorResponse,
88
getGithubGistBaseURL,
9+
hasGistSyncCredentials,
910
} from '@/utils/gist';
1011
import {
1112
normalizeArtifactSyncBatchSize,
@@ -57,6 +58,23 @@ describe('Gist GitHub API URL', function () {
5758
);
5859
});
5960

61+
it('allows token-only Gist-compatible sync settings', function () {
62+
expect(
63+
hasGistSyncCredentials({
64+
gistToken: 'token',
65+
githubUser: '',
66+
}),
67+
).to.equal(true);
68+
});
69+
70+
it('skips sync when the token is missing', function () {
71+
expect(
72+
hasGistSyncCredentials({
73+
githubUser: 'xream',
74+
}),
75+
).to.equal(false);
76+
});
77+
6078
it('normalizes artifact sync batch size', function () {
6179
expect(normalizeArtifactSyncBatchSize()).to.equal(10);
6280
expect(normalizeArtifactSyncBatchSize('')).to.equal(10);

backend/src/utils/gist.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export function getGithubGistBaseURL({ githubApiUrl, githubProxy } = {}) {
4444
}${DEFAULT_GITHUB_API_URL}`;
4545
}
4646

47+
export function hasGistSyncCredentials(settings = {}) {
48+
return Boolean(settings?.gistToken);
49+
}
50+
4751
export { describeGistApiErrorResponse };
4852

4953
/**

scripts/demo.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ function operator(proxies = [], targetPlatform, context) {
2323
// 10. `sni` 在某些协议里会自动与 `servername` 转换
2424
// 11. 读取节点的 ca-str 和 _ca (后端文件路径) 字段, 自动计算 fingerprint (参考 https://t.me/zhetengsha/1512)
2525
// 12. 以 Surge 为例, 最新的参数一般我都会跟进, 以 Surge 文档为例, 一些常用的: TUIC/Hysteria 2 的 `ecn`, Snell 的 `reuse` 连接复用, QUIC 策略 block-quic`, Hysteria 2 下载带宽 `down`
26+
// Surge 的 `private-key`/`client-cert` 对应内部字段 `keystore-private-key`/`keystore-client-cert`, 值都是 Surge `[Keystore]` 里的密钥库条目名, 不是证书/密钥内容或本地路径
27+
// 例如: $server['keystore-private-key'] = 'ssh-key-name' 输出 Surge SSH 时会生成 private-key="ssh-key-name"
28+
// 例如: $server['keystore-client-cert'] = 'client-cert-name' 输出 Surge TLS 代理时会生成 client-cert="client-cert-name"
2629
// 13. `test-url` 为测延迟链接, `test-timeout` 为测延迟超时
2730
// 14. `ports` 为端口跳跃, `hop-interval` 变换端口号的时间间隔
2831
// 15. `ip-version` 设置节点使用 IP 版本,兼容各家的值. 会进行内部转换. sing-box 以外: 若无法匹配则使用原始值. sing-box: 需有匹配且节点上设置 `_dns_server` 字段, 将自动设置 `domain_resolver.server`. 同时, `sing-box` 支持使用完整的 `_domain_resolver` 结构设置 `domain_resolver` 字段

0 commit comments

Comments
 (0)