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.
This commit is contained in:
Rouzbeh
2026-10-24 06:44:24 +02:00
committed by MHSanaei
parent 3af0f81181
commit 2b759f3916
12 changed files with 139 additions and 5 deletions
+2
View File
@@ -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,
};
+13
View File
@@ -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
@@ -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;
+6 -1
View File
@@ -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')}
>
<CipherSuitesSelect />
<CipherSuitesSelect
disabled={!isCipherSuitesEnabled}
placeholder={t('pages.inbounds.form.autoOption')}
/>
</FormField>
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
@@ -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(),
});
+2
View File
@@ -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"', () => {
+27
View File
@@ -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<string, unknown> | undefined;
const tls = stream?.tlsSettings as Record<string, unknown> | 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',
);
});
});
@@ -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<string, unknown>;
const tls = stream.tlsSettings as Record<string, unknown>;
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<string, unknown>).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<string, unknown>;
const tls = stream.tlsSettings as Record<string, unknown>;
expect(tls.cipherSuites).toBe('TLS_AES_256_GCM_SHA384');
});
});
describe('parseShadowsocksLink', () => {