mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/MHSanaei/3x-ui.git
synced 2026-09-20 08:04:05 +08:00
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.
533 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|