From 2b759f3916f9be28d5b3f687b701e6536d40da6b Mon Sep 17 00:00:00 2001
From: Rouzbeh <78313022+rqzbeh@users.noreply.github.com>
Date: Sat, 24 Oct 2026 06:44:24 +0200
Subject: [PATCH] feat(sub): export TLS cipher suites as the cs query param in
share links (#6825)
Hosts and TLS inbounds can set cipher suites, but they reached only the JSON and
Clash subscriptions; raw share links never carried them, so clients that read cs
from links (v2rayNG forks used with fp=unsafe) had to enter them by hand. Share
links now emit cs from the host or inbound TLS settings, the panel's own link
generator and previews do the same, and both link parsers (the Go one and the
panel's outbound import) read cs back into tlsSettings.cipherSuites.
---
frontend/src/lib/hosts/host-link.ts | 2 ++
frontend/src/lib/xray/inbound-link.ts | 13 +++++++++
frontend/src/lib/xray/outbound-link-parser.ts | 3 ++
frontend/src/pages/hosts/HostFormModal.tsx | 7 ++++-
.../protocols/stream/external-proxy.ts | 1 +
frontend/src/test/host-link.test.ts | 2 ++
frontend/src/test/inbound-link.test.ts | 27 +++++++++++++++++
.../src/test/outbound-link-parser.test.ts | 11 +++++++
internal/sub/service.go | 27 +++++++++++++++--
internal/sub/service_test.go | 29 +++++++++++++++++++
internal/util/link/outbound.go | 5 ++++
internal/util/link/outbound_helpers_test.go | 17 +++++++++--
12 files changed, 139 insertions(+), 5 deletions(-)
diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts
index 7dc75d202..5c44c76e5 100644
--- a/frontend/src/lib/hosts/host-link.ts
+++ b/frontend/src/lib/hosts/host-link.ts
@@ -18,6 +18,7 @@ export type HostLinkInput = Pick<
| 'pinnedPeerCertSha256'
| 'verifyPeerCertByName'
| 'echConfigList'
+ | 'cipherSuites'
| 'overrideSniFromAddress'
| 'keepSniBlank'
| 'vlessRoute'
@@ -55,6 +56,7 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
: undefined,
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
echConfigList: host.echConfigList || undefined,
+ cipherSuites: host.cipherSuites || undefined,
vlessRoute: host.vlessRoute || undefined,
allowInsecure: host.allowInsecure || undefined,
};
diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts
index 58215b1f2..0ed703cd9 100644
--- a/frontend/src/lib/xray/inbound-link.ts
+++ b/frontend/src/lib/xray/inbound-link.ts
@@ -235,6 +235,8 @@ function applyExternalProxyTLSObj(
}
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0)
obj.ech = externalProxy.echConfigList;
+ if (externalProxy.cipherSuites && externalProxy.cipherSuites.length > 0)
+ obj.cs = externalProxy.cipherSuites;
}
export interface GenVmessLinkInput {
@@ -337,6 +339,9 @@ export function genVmessLink(input: GenVmessLinkInput): string {
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
}
+ if (tlsSettings.cipherSuites && tlsSettings.cipherSuites.length > 0) {
+ obj.cs = tlsSettings.cipherSuites;
+ }
}
applyExternalProxyTLSObj(externalProxy, obj, tls);
@@ -394,6 +399,8 @@ function applyExternalProxyTLSParams(
}
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0)
params.set('ech', externalProxy.echConfigList);
+ if (externalProxy.cipherSuites && externalProxy.cipherSuites.length > 0)
+ params.set('cs', externalProxy.cipherSuites);
}
export interface GenVlessLinkInput {
@@ -497,6 +504,9 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
+ if (tls.cipherSuites && tls.cipherSuites.length > 0) {
+ params.set('cs', tls.cipherSuites);
+ }
}
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
@@ -602,6 +612,9 @@ function writeTlsParams(
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
+ if (tls.cipherSuites && tls.cipherSuites.length > 0) {
+ params.set('cs', tls.cipherSuites);
+ }
}
// Reality query-string writer shared by VLESS and Trojan. Preserves the
diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts
index 30659462e..6e83d988f 100644
--- a/frontend/src/lib/xray/outbound-link-parser.ts
+++ b/frontend/src/lib/xray/outbound-link-parser.ts
@@ -489,6 +489,8 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
tls.echConfigList = params.get('ech') ?? '';
tls.verifyPeerCertByName = params.get('vcn') ?? '';
tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
+ const cs = params.get('cs');
+ if (cs) tls.cipherSuites = cs;
} else if (stream.security === 'reality') {
const reality = stream.realitySettings as Raw;
reality.serverName = params.get('sni') ?? '';
@@ -567,6 +569,7 @@ export function parseVmessLink(link: string): Raw | null {
if (typeof json.ech === 'string') tls.echConfigList = json.ech;
if (typeof json.vcn === 'string') tls.verifyPeerCertByName = json.vcn;
if (typeof json.pcs === 'string') tls.pinnedPeerCertSha256 = json.pcs;
+ if (typeof json.cs === 'string') tls.cipherSuites = json.cs;
}
const port = Number(json.port) || 443;
diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx
index 2cae68ed1..b126cff65 100644
--- a/frontend/src/pages/hosts/HostFormModal.tsx
+++ b/frontend/src/pages/hosts/HostFormModal.tsx
@@ -106,10 +106,12 @@ export default function HostFormModal({
const [loading, setLoading] = useState(false);
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
+ const fingerprint = (useWatch({ control: methods.control, name: 'fingerprint' }) ?? '') as string;
const hostEchConfig = useWatch({ control: methods.control, name: 'echConfigList' }) ?? '';
const hostSni = useWatch({ control: methods.control, name: 'sni' }) ?? '';
const showTls = security === 'tls' || security === 'reality' || security === 'same';
const showTlsExtras = security === 'tls' || security === 'same';
+ const isCipherSuitesEnabled = showTlsExtras && (!fingerprint || fingerprint === 'unsafe');
const watchedHosts = useWatch({ control: methods.control, name: 'hosts' });
const hasCidr = Boolean(watchedHosts?.some((h) => typeof h === 'string' && h.includes('/')));
@@ -364,7 +366,10 @@ export default function HostFormModal({
name="cipherSuites"
label={t('pages.inbounds.form.cipherSuites')}
>
-
+
diff --git a/frontend/src/schemas/protocols/stream/external-proxy.ts b/frontend/src/schemas/protocols/stream/external-proxy.ts
index 86a6b243d..3b7f7585e 100644
--- a/frontend/src/schemas/protocols/stream/external-proxy.ts
+++ b/frontend/src/schemas/protocols/stream/external-proxy.ts
@@ -25,6 +25,7 @@ export const ExternalProxyEntrySchema = z.object({
pinnedPeerCertSha256: z.array(z.string()).optional(),
verifyPeerCertByName: z.string().optional(),
echConfigList: z.string().optional(),
+ cipherSuites: z.string().optional(),
vlessRoute: z.string().optional(),
allowInsecure: z.boolean().optional(),
});
diff --git a/frontend/src/test/host-link.test.ts b/frontend/src/test/host-link.test.ts
index 9041faf65..0c3362c60 100644
--- a/frontend/src/test/host-link.test.ts
+++ b/frontend/src/test/host-link.test.ts
@@ -16,6 +16,7 @@ describe('hostToExternalProxyEntry', () => {
pinnedPeerCertSha256: ['AAAA'],
verifyPeerCertByName: 'verify.example.com',
echConfigList: 'ECH',
+ cipherSuites: 'TLS_AES_128_GCM_SHA256',
overrideSniFromAddress: false,
keepSniBlank: false,
vlessRoute: '',
@@ -34,6 +35,7 @@ describe('hostToExternalProxyEntry', () => {
expect(ep.pinnedPeerCertSha256).toEqual(['AAAA']);
expect(ep.verifyPeerCertByName).toBe('verify.example.com');
expect(ep.echConfigList).toBe('ECH');
+ expect(ep.cipherSuites).toBe('TLS_AES_128_GCM_SHA256');
});
it('maps reality/same security to forceTls "same"', () => {
diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts
index f5b368162..bc40e9d77 100644
--- a/frontend/src/test/inbound-link.test.ts
+++ b/frontend/src/test/inbound-link.test.ts
@@ -1496,4 +1496,31 @@ describe('isPostQuantumLink', () => {
});
expect(isPostQuantumLink(link)).toBe(true);
});
+
+ it('exports cipherSuites as cs query parameter on VLESS TLS links', () => {
+ const [, rawFixture] = fixturesForProtocol('vless')[0];
+ const stream = rawFixture.streamSettings as Record | undefined;
+ const tls = stream?.tlsSettings as Record | undefined;
+ const typed = InboundSchema.parse({
+ ...rawFixture,
+ streamSettings: {
+ ...stream,
+ security: 'tls',
+ tlsSettings: {
+ ...tls,
+ cipherSuites: 'TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256',
+ },
+ },
+ });
+
+ const link = genVlessLink({
+ inbound: typed,
+ address: 'example.test',
+ port: 443,
+ clientId: '11111111-2222-3333-4444-555555555555',
+ });
+ expect(new URL(link).searchParams.get('cs')).toBe(
+ 'TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256',
+ );
+ });
});
diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts
index 907615035..0b6d06d24 100644
--- a/frontend/src/test/outbound-link-parser.test.ts
+++ b/frontend/src/test/outbound-link-parser.test.ts
@@ -75,6 +75,7 @@ describe('parseVmessLink', () => {
ech: 'AEX+DQBB',
vcn: 'vcn.example.com',
pcs: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
+ cs: 'TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256',
};
const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
const out = parseVmessLink(link);
@@ -82,6 +83,7 @@ describe('parseVmessLink', () => {
const stream = out?.streamSettings as Record;
const tls = stream.tlsSettings as Record;
expect(tls.echConfigList).toBe('AEX+DQBB');
+ expect(tls.cipherSuites).toBe('TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256');
expect(tls.verifyPeerCertByName).toBe('vcn.example.com');
expect(tls.pinnedPeerCertSha256).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
});
@@ -428,6 +430,15 @@ describe('parseTrojanLink', () => {
expect(stream.network).toBe('ws');
expect((stream.wsSettings as Record).path).toBe('/tj');
});
+
+ it('reads the cs cipher suites into tlsSettings, as the Go link parser does', () => {
+ const out = parseTrojanLink(
+ 'trojan://pw@srv.example:443?type=tcp&security=tls&sni=srv.example&fp=unsafe&cs=TLS_AES_256_GCM_SHA384#cs',
+ );
+ const stream = out!.streamSettings as Record;
+ const tls = stream.tlsSettings as Record;
+ expect(tls.cipherSuites).toBe('TLS_AES_256_GCM_SHA384');
+ });
});
describe('parseShadowsocksLink', () => {
diff --git a/internal/sub/service.go b/internal/sub/service.go
index 49a3bbefd..681d93ae9 100644
--- a/internal/sub/service.go
+++ b/internal/sub/service.go
@@ -1867,6 +1867,11 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
if pins, ok := pinnedSha256List(tlsSettings); ok {
params["pcs"] = strings.Join(pins, ",")
}
+ if csValue, ok := searchKey(tlsSetting, "cipherSuites"); ok {
+ if cs, _ := csValue.(string); cs != "" {
+ params["cs"] = cs
+ }
+ }
}
}
@@ -1902,6 +1907,11 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
if pins, ok := pinnedSha256List(tlsSettings); ok {
obj["pcs"] = strings.Join(pins, ",")
}
+ if csValue, ok := searchKey(tlsSetting, "cipherSuites"); ok {
+ if cs, _ := csValue.(string); cs != "" {
+ obj["cs"] = cs
+ }
+ }
}
}
@@ -2048,7 +2058,7 @@ func buildVmessLink(obj map[string]any) string {
func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any {
newObj := map[string]any{}
for key, value := range baseObj {
- if newSecurity != "none" || (key != "alpn" && key != "sni" && key != "fp" && key != "pcs") {
+ if newSecurity != "none" || (key != "alpn" && key != "sni" && key != "fp" && key != "pcs" && key != "cs") {
newObj[key] = value
}
}
@@ -2077,6 +2087,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
obj["ech"] = ech
}
+ if cs, ok := externalProxyCipherSuites(ep); ok {
+ obj["cs"] = cs
+ }
}
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
@@ -2101,6 +2114,9 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
params["ech"] = ech
}
+ if cs, ok := externalProxyCipherSuites(ep); ok {
+ params["cs"] = cs
+ }
}
// applyExternalProxyHysteriaParams overrides the cert pin for a single
@@ -2211,6 +2227,13 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
}
}
+func externalProxyCipherSuites(ep map[string]any) (string, bool) {
+ if cs, ok := ep["cipherSuites"].(string); ok && cs != "" {
+ return cs, true
+ }
+ return "", false
+}
+
func externalProxySNI(ep map[string]any) (string, bool) {
if sni, ok := ep["sni"].(string); ok && sni != "" {
return sni, true
@@ -2357,7 +2380,7 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
if securityOverride != "" && k == "security" {
v = securityOverride
}
- if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp" || k == "pcs") {
+ if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp" || k == "pcs" || k == "cs") {
continue
}
q.Set(k, v)
diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go
index 32c5806e1..46a5bf36c 100644
--- a/internal/sub/service_test.go
+++ b/internal/sub/service_test.go
@@ -830,6 +830,35 @@ func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
}
}
+func TestApplyExternalProxyTLSParams_SetsCipherSuites(t *testing.T) {
+ params := map[string]string{"security": "tls"}
+ ep := map[string]any{
+ "dest": "proxy.example.com",
+ "cipherSuites": "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
+ }
+
+ applyExternalProxyTLSParams(ep, params, "tls")
+
+ if params["cs"] != "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256" {
+ t.Fatalf("cs = %q, want TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256", params["cs"])
+ }
+}
+
+func TestApplyShareTLSParams_SetsCipherSuites(t *testing.T) {
+ stream := map[string]any{
+ "security": "tls",
+ "tlsSettings": map[string]any{
+ "cipherSuites": "TLS_AES_128_GCM_SHA256",
+ },
+ }
+ params := map[string]string{}
+ applyShareTLSParams(stream, params)
+
+ if params["cs"] != "TLS_AES_128_GCM_SHA256" {
+ t.Fatalf("cs = %q, want TLS_AES_128_GCM_SHA256", params["cs"])
+ }
+}
+
func TestApplyExternalProxyTLSObj_SetsPinnedPeerCert(t *testing.T) {
obj := map[string]any{"tls": "tls"}
ep := map[string]any{
diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go
index b29af0f2f..c543dbfc4 100644
--- a/internal/util/link/outbound.go
+++ b/internal/util/link/outbound.go
@@ -235,6 +235,7 @@ func parseVmess(link string) (*ParseResult, error) {
tls["echConfigList"] = getString(j, "ech", "")
tls["verifyPeerCertByName"] = getString(j, "vcn", "")
tls["pinnedPeerCertSha256"] = getString(j, "pcs", "")
+ tls["cipherSuites"] = getString(j, "cs", "")
}
port := num(j["port"])
@@ -687,6 +688,7 @@ func buildStream(network, security string) map[string]any {
stream["tlsSettings"] = map[string]any{
"serverName": "", "alpn": []any{}, "fingerprint": "",
"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
+ "cipherSuites": "",
}
case "reality":
stream["realitySettings"] = map[string]any{
@@ -783,6 +785,9 @@ func applySecurity(stream map[string]any, p url.Values) {
tls["echConfigList"] = p.Get("ech")
tls["verifyPeerCertByName"] = p.Get("vcn")
tls["pinnedPeerCertSha256"] = p.Get("pcs")
+ if cs := p.Get("cs"); cs != "" {
+ tls["cipherSuites"] = cs
+ }
case "reality":
re := stream["realitySettings"].(map[string]any)
re["serverName"] = p.Get("sni")
diff --git a/internal/util/link/outbound_helpers_test.go b/internal/util/link/outbound_helpers_test.go
index 3ff90d275..4ecf390c5 100644
--- a/internal/util/link/outbound_helpers_test.go
+++ b/internal/util/link/outbound_helpers_test.go
@@ -146,12 +146,12 @@ func TestParse_RealitySettingsAreXrayFields(t *testing.T) {
}
func TestParse_TLSSecurityMapped(t *testing.T) {
- res, err := ParseLink("trojan://pw@h.com:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&vcn=VCN&pcs=PCS")
+ res, err := ParseLink("trojan://pw@h.com:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&vcn=VCN&pcs=PCS&cs=TLS_AES_128_GCM_SHA256")
if err != nil {
t.Fatalf("parse: %v", err)
}
tls := streamSub(t, res, "tlsSettings")
- if tls["serverName"] != "SNI" || tls["fingerprint"] != "chrome" || tls["echConfigList"] != "ECH" || tls["verifyPeerCertByName"] != "VCN" || tls["pinnedPeerCertSha256"] != "PCS" {
+ if tls["serverName"] != "SNI" || tls["fingerprint"] != "chrome" || tls["echConfigList"] != "ECH" || tls["verifyPeerCertByName"] != "VCN" || tls["pinnedPeerCertSha256"] != "PCS" || tls["cipherSuites"] != "TLS_AES_128_GCM_SHA256" {
t.Errorf("tlsSettings fields = %#v", tls)
}
if alpn, _ := tls["alpn"].([]string); !reflect.DeepEqual(alpn, []string{"h2", "http/1.1"}) {
@@ -259,6 +259,19 @@ func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
}
}
+func TestParse_VmessCipherSuites(t *testing.T) {
+ inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","tls":"tls","cs":"TLS_AES_128_GCM_SHA256"}`
+ link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
+ res, err := ParseLink(link)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ tls := streamSub(t, res, "tlsSettings")
+ if tls["cipherSuites"] != "TLS_AES_128_GCM_SHA256" {
+ t.Errorf("tlsSettings cipherSuites = %v, want TLS_AES_128_GCM_SHA256", tls["cipherSuites"])
+ }
+}
+
func TestParse_Hysteria2VerifyPeerCertByName(t *testing.T) {
res, err := ParseLink("hysteria2://auth@h.com:443?security=tls&sni=decoy.com&vcn=real-cert.com#r")
if err != nil {