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 {