Files
3x-ui/frontend/src/pages/hosts/HostFormModal.tsx
Sanaei 17e89db979 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.
2026-09-16 11:56:20 +02:00

533 lines
20 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
import {
ProfileOutlined,
SafetyCertificateOutlined,
ControlOutlined,
NodeIndexOutlined,
SettingOutlined,
PartitionOutlined,
DeploymentUnitOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
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';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
type FormShape = Omit<BulkAddHostValues, 'isDisabled'> & {
enable: boolean;
};
interface HostFormModalProps {
open: boolean;
mode: 'add' | 'edit';
host: HostRecord | null;
inboundOptions: InboundOption[];
existingHosts: HostRecord[];
save: (payload: BulkAddHostValues) => Promise<{ success?: boolean; msg?: string } | undefined>;
onOpenChange: (open: boolean) => void;
}
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
function defaultsFor(host: HostRecord | null): FormShape {
return {
inboundIds: host?.inboundIds ?? [],
hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''),
sortOrder: host?.sortOrder ?? 0,
remark: host?.remark ?? '',
serverDescription: host?.serverDescription ?? '',
enable: host ? !host.isDisabled : true,
isHidden: host?.isHidden ?? false,
tags: host?.tags ?? [],
port: host?.port ?? 0,
security: (host?.security as BulkAddHostValues['security']) ?? 'same',
sni: host?.sni ?? '',
hostHeader: host?.hostHeader ?? '',
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 ?? [],
verifyPeerCertByName: (host?.verifyPeerCertByName as string | undefined) ?? '',
allowInsecure: host?.allowInsecure ?? false,
echConfigList: host?.echConfigList ?? '',
muxParams: asString(host?.muxParams),
sockoptParams: asString(host?.sockoptParams),
finalMask: host?.finalMask ?? '',
vlessRoute: host?.vlessRoute ?? '',
excludeFromSubTypes:
(host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
nodeGuids: host?.nodeGuids ?? [],
mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
mihomoX25519: host?.mihomoX25519 ?? false,
shuffleHost: host?.shuffleHost ?? false,
};
}
export default function HostFormModal({
open,
mode,
host,
inboundOptions,
existingHosts,
save,
onOpenChange,
}: HostFormModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
const showTls = security === 'tls' || security === 'reality' || security === 'same';
const showTlsExtras = security === 'tls' || security === 'same';
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const openHost = open ? host : null;
const [syncedHost, setSyncedHost] = useState(openHost);
if (openHost !== syncedHost) {
setSyncedHost(openHost);
if (open) {
methods.reset(defaultsFor(host));
setLoading(false);
}
}
const { nodes } = useNodesQuery();
const inboundSelectOptions = useMemo(
() =>
inboundOptions.map((ib) => ({
value: ib.id,
label: ib.remark || ib.tag || `#${ib.id}`,
})),
[inboundOptions],
);
const nodeSelectOptions = useMemo(
() =>
nodes
.filter((n) => n.guid)
.map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
[nodes],
);
const alpnOptions = useMemo(
() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })),
[],
);
const fpOptions = useMemo(
// '' = None first: Hysteria (and any no-uTLS host) must be selectable.
() => [
{ value: '', label: t('none') },
...Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
],
[t],
);
const hostOptions = useMemo(() => {
const addresses = new Set<string>();
for (const h of existingHosts || []) {
if (h.hosts) {
for (const addr of h.hosts) {
if (addr && addr.trim() !== '') {
addresses.add(addr);
}
}
}
}
return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
}, [existingHosts]);
const onFinish = async (values: FormShape) => {
if (loading) return;
const { enable, ...rest } = values;
const isDisabled = !enable;
const payload: BulkAddHostValues = {
...rest,
hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
isDisabled,
};
setLoading(true);
try {
const res = await save(payload);
if (res?.success) {
messageApi.success(
t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'),
);
onOpenChange(false);
} else if (res?.msg) {
messageApi.error(res.msg);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<Modal
open={open}
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
onOk={methods.handleSubmit(onFinish)}
onCancel={() => onOpenChange(false)}
confirmLoading={loading}
okText={t('save')}
cancelText={t('cancel')}
destroyOnHidden
width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
>
{messageContextHolder}
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
>
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
forceRender: true,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<FormField
name="remark"
label={t('pages.hosts.fields.remark')}
tooltip={t('pages.hosts.hints.remark')}
rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}
>
<Input maxLength={256} />
</FormField>
<FormField
name="serverDescription"
label={t('pages.hosts.fields.serverDescription')}
tooltip={t('pages.hosts.hints.serverDescription')}
>
<Input maxLength={64} />
</FormField>
<FormField
name="inboundIds"
label={t('pages.hosts.fields.inbound')}
rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}
>
<Select
mode="multiple"
options={inboundSelectOptions}
showSearch={{ optionFilterProp: 'label' }}
placeholder={t('pages.hosts.selectInbound')}
/>
</FormField>
<FormField
name="hosts"
label={t('pages.hosts.fields.address')}
tooltip={t('pages.hosts.hints.address')}
rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}
>
<Select
mode="tags"
options={hostOptions}
tokenSeparators={[',', ';', ' ']}
placeholder="cdn.example.com, cdn2.example.com:443"
/>
</FormField>
<FormField
name="port"
label={t('pages.hosts.fields.port')}
tooltip={t('pages.hosts.hints.port')}
>
<InputNumber min={0} max={65535} />
</FormField>
<FormField
name="tags"
label={t('pages.hosts.fields.tags')}
tooltip={t('pages.hosts.hints.tags')}
>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField
name="nodeGuids"
label={t('pages.hosts.fields.nodeGuids')}
tooltip={t('pages.hosts.hints.nodeGuids')}
>
<Select
mode="multiple"
allowClear
options={nodeSelectOptions}
showSearch={{ optionFilterProp: 'label' }}
/>
</FormField>
<FormField
name="enable"
label={t('pages.hosts.fields.enable')}
valueProp="checked"
>
<Switch />
</FormField>
</>
),
},
{
key: 'security',
forceRender: true,
label: catTabLabel(
<SafetyCertificateOutlined />,
t('pages.hosts.sections.security'),
isMobile,
),
children: (
<>
<FormField name="security" label={t('pages.hosts.fields.security')}>
<Select
options={['same', 'tls', 'none', 'reality'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
{showTls && (
<>
<FormField name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
</FormField>
<FormField
name="overrideSniFromAddress"
label={t('pages.hosts.fields.overrideSniFromAddress')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name="keepSniBlank"
label={t('pages.hosts.fields.keepSniBlank')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
<Select allowClear options={fpOptions} />
</FormField>
</>
)}
{showTlsExtras && (
<>
<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>
<FormField
name="verifyPeerCertByName"
label={t('pages.hosts.fields.verifyPeerCertByName')}
tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
>
<Input placeholder="example.com" />
</FormField>
<FormField
name="allowInsecure"
label={t('pages.hosts.fields.allowInsecure')}
tooltip={t('pages.hosts.hints.allowInsecure')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name="echConfigList"
label={t('pages.hosts.fields.echConfigList')}
>
<Input.TextArea rows={2} />
</FormField>
</>
)}
</>
),
},
{
key: 'advanced',
forceRender: true,
label: catTabLabel(
<ControlOutlined />,
t('pages.hosts.sections.advanced'),
isMobile,
),
children: (
<Tabs
size="small"
defaultActiveKey="adv-general"
items={[
{
key: 'adv-general',
forceRender: true,
label: catTabLabel(
<SettingOutlined />,
t('pages.hosts.sections.general'),
isMobile,
),
children: (
<>
<FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
<Input />
</FormField>
<FormField name="path" label={t('pages.hosts.fields.path')}>
<Input />
</FormField>
<FormField
name="vlessRoute"
label={t('pages.hosts.fields.vlessRoute')}
tooltip={t('pages.hosts.hints.vlessRoute')}
>
<Input placeholder="443" />
</FormField>
<FormField
name="excludeFromSubTypes"
label={t('pages.hosts.fields.excludeFromSubTypes')}
>
<Select
mode="multiple"
allowClear
options={['raw', 'json', 'clash'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
</>
),
},
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(
<PartitionOutlined />,
t('pages.hosts.fields.muxParams'),
isMobile,
),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="muxParams"
render={({ field }) => (
<HostMuxForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
),
},
{
key: 'adv-sockopt',
forceRender: true,
label: catTabLabel(
<DeploymentUnitOutlined />,
t('pages.hosts.fields.sockoptParams'),
isMobile,
),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="sockoptParams"
render={({ field }) => (
<HostSockoptForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
),
},
{
key: 'adv-finalmask',
forceRender: true,
label: catTabLabel(
<RocketOutlined />,
t('pages.hosts.fields.finalMask'),
isMobile,
),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="finalMask"
render={({ field }) => (
<HostFinalMaskForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
),
},
]}
/>
),
},
{
key: 'clash',
forceRender: true,
label: catTabLabel(
<NodeIndexOutlined />,
t('pages.hosts.sections.clash'),
isMobile,
),
children: (
<>
<FormField
name="mihomoIpVersion"
label={t('pages.hosts.fields.mihomoIpVersion')}
>
<Select
allowClear
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map(
(v) => ({ value: v, label: v }),
)}
/>
</FormField>
<FormField
name="mihomoX25519"
label={t('pages.hosts.fields.mihomoX25519')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name="shuffleHost"
label={t('pages.hosts.fields.shuffleHost')}
valueProp="checked"
>
<Switch />
</FormField>
</>
),
},
]}
/>
</Form>
</FormProvider>
</Modal>
);
}