mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-18 16:17:16 +00:00
feat(hosts): add a cipher suites override and accept custom suites
The inbound TLS form offered cipherSuites as a closed single-choice list, but xray reads the value as a colon-separated list and accepts any name Go knows, so several suites or one missing from the list could not be set. Both the inbound and the new host field now use a tag picker that keeps the stored value as the colon-joined string xray expects; old single values open unchanged. A host's cipher suites replace the inbound's in the JSON subscription stream, and a blank field inherits them. Share links and Clash carry no cipher suite parameter, so their output is unchanged.
This commit is contained in:
@@ -2301,6 +2301,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2434,6 +2437,7 @@
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2478,6 +2482,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2604,6 +2611,7 @@
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
@@ -11268,6 +11276,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11362,6 +11371,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11459,6 +11469,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11609,6 +11620,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11730,6 +11742,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11981,6 +11994,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Select } from 'antd';
|
||||
import type { SelectProps } from 'antd';
|
||||
|
||||
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
|
||||
|
||||
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
|
||||
|
||||
type CipherSuitesSelectProps = Omit<
|
||||
SelectProps<string[]>,
|
||||
'value' | 'onChange' | 'mode' | 'options'
|
||||
> & {
|
||||
// Injected by FormField:
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
|
||||
// the stored value stays the single colon-joined string xray reads.
|
||||
export default function CipherSuitesSelect({
|
||||
value = '',
|
||||
onChange,
|
||||
...rest
|
||||
}: CipherSuitesSelectProps) {
|
||||
const suites = value
|
||||
.split(':')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return (
|
||||
<Select
|
||||
allowClear
|
||||
tokenSeparators={[':', ',']}
|
||||
{...rest}
|
||||
mode="tags"
|
||||
options={CIPHER_SUITE_OPTIONS}
|
||||
value={suites}
|
||||
onChange={(next) => onChange?.(next.join(':'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { default as JsonEditor } from './JsonEditor';
|
||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
|
||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||
export { default as CipherSuitesSelect } from './CipherSuitesSelect';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
||||
|
||||
@@ -591,6 +591,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -636,6 +637,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
|
||||
@@ -2275,6 +2275,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2408,6 +2411,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2452,6 +2456,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2578,6 +2585,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
|
||||
@@ -537,6 +537,7 @@ export interface Host {
|
||||
address: string;
|
||||
allowInsecure: boolean;
|
||||
alpn: string[];
|
||||
cipherSuites: string;
|
||||
createdAt: number;
|
||||
echConfigList: string;
|
||||
excludeFromSubTypes: string[];
|
||||
@@ -573,6 +574,7 @@ export interface Host {
|
||||
export interface HostGroup {
|
||||
allowInsecure: boolean;
|
||||
alpn: string[];
|
||||
cipherSuites: string;
|
||||
echConfigList: string;
|
||||
excludeFromSubTypes: string[];
|
||||
finalMask: string;
|
||||
|
||||
@@ -573,6 +573,7 @@ export const HostSchema = z.object({
|
||||
address: z.string(),
|
||||
allowInsecure: z.boolean(),
|
||||
alpn: z.array(z.string()),
|
||||
cipherSuites: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
echConfigList: z.string(),
|
||||
excludeFromSubTypes: z.array(z.string()),
|
||||
@@ -610,6 +611,7 @@ export type Host = z.infer<typeof HostSchema>;
|
||||
export const HostGroupSchema = z.object({
|
||||
allowInsecure: z.boolean(),
|
||||
alpn: z.array(z.string()),
|
||||
cipherSuites: z.string(),
|
||||
echConfigList: z.string(),
|
||||
excludeFromSubTypes: z.array(z.string()),
|
||||
finalMask: z.string(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
|
||||
path: host?.path ?? '',
|
||||
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
|
||||
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
|
||||
cipherSuites: host?.cipherSuites ?? '',
|
||||
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
|
||||
keepSniBlank: host?.keepSniBlank ?? false,
|
||||
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
|
||||
@@ -332,6 +334,12 @@ export default function HostFormModal({
|
||||
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="cipherSuites"
|
||||
label={t('pages.inbounds.form.cipherSuites')}
|
||||
>
|
||||
<CipherSuitesSelect />
|
||||
</FormField>
|
||||
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
</FormField>
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
ALPN_OPTION,
|
||||
DOMAIN_STRATEGY_OPTION,
|
||||
TLS_CIPHER_OPTION,
|
||||
TLS_VERSION_OPTION,
|
||||
USAGE_OPTION,
|
||||
UTLS_FINGERPRINT,
|
||||
@@ -240,12 +240,7 @@ export default function TlsForm({
|
||||
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
|
||||
label={t('pages.inbounds.form.cipherSuites')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: t('pages.inbounds.form.autoOption') },
|
||||
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
|
||||
]}
|
||||
/>
|
||||
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Space.Compact block>
|
||||
|
||||
@@ -35,6 +35,7 @@ export const HostFormSchema = z.object({
|
||||
(val) => (val === '' ? undefined : val),
|
||||
UtlsFingerprintSchema.optional(),
|
||||
),
|
||||
cipherSuites: z.string().default(''),
|
||||
overrideSniFromAddress: z.boolean().default(false),
|
||||
keepSniBlank: z.boolean().default(false),
|
||||
pinnedPeerCertSha256: z.array(z.string()).default([]),
|
||||
@@ -87,6 +88,7 @@ export const HostRecordSchema = z
|
||||
path: z.string().optional(),
|
||||
alpn: z.array(z.string()).nullish(),
|
||||
fingerprint: z.string().optional(),
|
||||
cipherSuites: z.string().optional(),
|
||||
overrideSniFromAddress: z.boolean().optional(),
|
||||
keepSniBlank: z.boolean().optional(),
|
||||
pinnedPeerCertSha256: z.array(z.string()).nullish(),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
|
||||
function renderSelect(value: string) {
|
||||
const onChange = vi.fn();
|
||||
render(<CipherSuitesSelect aria-label="cipher suites" value={value} onChange={onChange} />);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('CipherSuitesSelect', () => {
|
||||
it('shows each colon-separated suite as its own tag', () => {
|
||||
renderSelect('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE');
|
||||
expect(screen.getByText('TLS_AES_256_GCM_SHA384')).toBeTruthy();
|
||||
expect(screen.getByText('MY_CUSTOM_SUITE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stores a typed custom suite joined with colons after the existing one', () => {
|
||||
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
|
||||
const input = screen.getByRole('combobox', { name: 'cipher suites' });
|
||||
fireEvent.change(input, { target: { value: 'MY_CUSTOM_SUITE' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 });
|
||||
expect(onChange).toHaveBeenLastCalledWith('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE');
|
||||
});
|
||||
|
||||
it('stores an empty string once every suite is removed', () => {
|
||||
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
|
||||
const remove = document.querySelector('.ant-select-selection-item-remove');
|
||||
expect(remove).not.toBeNull();
|
||||
fireEvent.click(remove as Element);
|
||||
expect(onChange).toHaveBeenLastCalledWith('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user