mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
The hysteria masquerade form edits streamSettings.hysteriaSettings.masquerade (a transport/stream concept) and is rendered identically by both modals, so it belongs next to FinalMaskForm in lib/xray/forms/transport/ rather than protocols/shared/. Moved the file, updated the transport barrel + both consumers (inbound hysteria protocol form, outbound modal), and removed the now-empty protocols/shared/ folder. Pure relocation; snapshots unchanged, typecheck/lint/build green.
962 lines
43 KiB
TypeScript
962 lines
43 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
Button,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Radio,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Tabs,
|
||
message,
|
||
} from 'antd';
|
||
import { FinalMaskForm, HysteriaMasqueradeForm } from '@/lib/xray/forms/transport';
|
||
import { JsonEditor } from '@/components/form';
|
||
import { Wireguard } from '@/utils';
|
||
import {
|
||
XMUX_DEFAULTS,
|
||
formValuesToWirePayload,
|
||
rawOutboundToFormValues,
|
||
} from '@/lib/xray/outbound-form-adapter';
|
||
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
|
||
import {
|
||
OutboundFormBaseSchema,
|
||
type OutboundFormValues,
|
||
} from '@/schemas/forms/outbound-form';
|
||
import {
|
||
DOMAIN_STRATEGY_OPTION,
|
||
SNIFFING_OPTION,
|
||
TCP_CONGESTION_OPTION,
|
||
} from '@/schemas/primitives';
|
||
import {
|
||
HappyEyeballsSchema,
|
||
SockoptStreamSettingsSchema,
|
||
} from '@/schemas/protocols/stream/sockopt';
|
||
import {
|
||
canEnableReality,
|
||
canEnableStream,
|
||
canEnableTls,
|
||
canEnableTlsFlow,
|
||
} from '@/lib/xray/protocol-capabilities';
|
||
import { antdRule } from '@/utils/zodForm';
|
||
|
||
import {
|
||
ADDRESS_PORT_STRATEGY_OPTIONS,
|
||
FLOW_OPTIONS,
|
||
HYSTERIA_NETWORK_OPTION,
|
||
NETWORK_OPTIONS,
|
||
PROTOCOL_OPTIONS,
|
||
SERVER_PROTOCOLS,
|
||
} from './outbound-form-constants';
|
||
import {
|
||
buildAddModeValues,
|
||
hysteriaStreamSlice,
|
||
isMuxAllowed,
|
||
newStreamSlice,
|
||
} from './outbound-form-helpers';
|
||
import {
|
||
BlackholeFields,
|
||
DnsFields,
|
||
FreedomFields,
|
||
HttpFields,
|
||
LoopbackFields,
|
||
ServerTarget,
|
||
ShadowsocksFields,
|
||
SocksFields,
|
||
TrojanFields,
|
||
VlessFields,
|
||
VmessFields,
|
||
WireguardFields,
|
||
} from './protocols';
|
||
import {
|
||
GrpcForm,
|
||
HttpUpgradeForm,
|
||
KcpForm,
|
||
RawForm,
|
||
WsForm,
|
||
XhttpForm,
|
||
} from './transport';
|
||
import { RealityForm, TlsForm } from './security';
|
||
import './OutboundFormModal.css';
|
||
|
||
// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
|
||
// file so the build stays green section-by-section. The atomic swap at
|
||
// the end of the rewrite replaces the legacy file in one commit
|
||
// (per Core Decision 7 in the migration spec).
|
||
|
||
interface OutboundFormModalProps {
|
||
open: boolean;
|
||
outbound: Record<string, unknown> | null;
|
||
existingTags: string[];
|
||
onClose: () => void;
|
||
onConfirm: (outbound: Record<string, unknown>) => void;
|
||
}
|
||
|
||
|
||
export default function OutboundFormModal({
|
||
open,
|
||
outbound: outboundProp,
|
||
existingTags,
|
||
onClose,
|
||
onConfirm,
|
||
}: OutboundFormModalProps) {
|
||
const { t } = useTranslation();
|
||
const [messageApi, messageContextHolder] = message.useMessage();
|
||
const [form] = Form.useForm<OutboundFormValues>();
|
||
const [activeKey, setActiveKey] = useState('1');
|
||
const [jsonText, setJsonText] = useState('');
|
||
const [jsonDirty, setJsonDirty] = useState(false);
|
||
const [linkInput, setLinkInput] = useState('');
|
||
|
||
// Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
|
||
// hysteria2:// / wireguard://) and replace form state with the result.
|
||
// The current tag is preserved when the parsed link doesn't carry one.
|
||
function importLink() {
|
||
const link = linkInput.trim();
|
||
if (!link) return;
|
||
const parsed = parseOutboundLink(link);
|
||
if (!parsed) {
|
||
messageApi.error('Wrong Link!');
|
||
return;
|
||
}
|
||
const currentTag = form.getFieldValue('tag') as string | undefined;
|
||
if (!parsed.tag && currentTag) parsed.tag = currentTag;
|
||
const next = rawOutboundToFormValues(parsed);
|
||
form.resetFields();
|
||
form.setFieldsValue(next);
|
||
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
|
||
setJsonDirty(false);
|
||
setLinkInput('');
|
||
messageApi.success('Link imported successfully');
|
||
switchTab('1');
|
||
}
|
||
|
||
const isEdit = outboundProp != null;
|
||
const title = isEdit
|
||
? `${t('edit')} ${t('pages.xray.Outbounds')}`
|
||
: `+ ${t('pages.xray.Outbounds')}`;
|
||
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const initial = outboundProp
|
||
? rawOutboundToFormValues(outboundProp)
|
||
: buildAddModeValues();
|
||
form.resetFields();
|
||
form.setFieldsValue(initial);
|
||
setActiveKey('1');
|
||
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
|
||
setJsonDirty(false);
|
||
}, [open, outboundProp, form]);
|
||
|
||
const tag = Form.useWatch('tag', form) ?? '';
|
||
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
|
||
const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
|
||
const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
|
||
const streamAllowed = canEnableStream({ protocol });
|
||
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
||
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
|
||
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
|
||
|
||
useEffect(() => {
|
||
if (!streamAllowed) return;
|
||
if (network) return;
|
||
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [streamAllowed, network]);
|
||
|
||
useEffect(() => {
|
||
if (protocol !== 'hysteria') return;
|
||
if (network === 'hysteria' && security === 'tls') return;
|
||
const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
|
||
const slice = hysteriaStreamSlice();
|
||
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
|
||
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
|
||
form.setFieldValue('streamSettings', slice);
|
||
}, [protocol, network, security]);
|
||
|
||
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
|
||
useEffect(() => {
|
||
if (protocol !== 'wireguard') return;
|
||
const sk = (wgSecretKey ?? '').trim();
|
||
if (!sk) {
|
||
form.setFieldValue(['settings', 'pubKey'], '');
|
||
return;
|
||
}
|
||
try {
|
||
const { publicKey } = Wireguard.generateKeypair(sk);
|
||
form.setFieldValue(['settings', 'pubKey'], publicKey);
|
||
} catch {
|
||
form.setFieldValue(['settings', 'pubKey'], '');
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [protocol, wgSecretKey]);
|
||
|
||
function onValuesChange(changed: Partial<OutboundFormValues>) {
|
||
if ('protocol' in changed && changed.protocol) {
|
||
const next = rawOutboundToFormValues({ protocol: changed.protocol });
|
||
form.setFieldValue('settings', next.settings);
|
||
if (changed.protocol === 'hysteria') {
|
||
form.setFieldValue('streamSettings', hysteriaStreamSlice());
|
||
} else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
|
||
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
|
||
}
|
||
}
|
||
}
|
||
|
||
function onSecurityChange(next: string) {
|
||
const stream = form.getFieldValue('streamSettings') ?? {};
|
||
const cleaned = { ...stream } as Record<string, unknown>;
|
||
delete cleaned.tlsSettings;
|
||
delete cleaned.realitySettings;
|
||
if (next === 'tls') {
|
||
cleaned.tlsSettings = {
|
||
serverName: '',
|
||
alpn: [],
|
||
fingerprint: '',
|
||
echConfigList: '',
|
||
verifyPeerCertByName: '',
|
||
pinnedPeerCertSha256: '',
|
||
};
|
||
} else if (next === 'reality') {
|
||
cleaned.realitySettings = {
|
||
publicKey: '',
|
||
fingerprint: 'chrome',
|
||
serverName: '',
|
||
shortId: '',
|
||
spiderX: '',
|
||
mldsa65Verify: '',
|
||
};
|
||
}
|
||
cleaned.security = next;
|
||
form.setFieldValue('streamSettings', cleaned);
|
||
}
|
||
|
||
// Network change cascade: swap the per-network sub-key (tcpSettings,
|
||
// wsSettings, etc.) so the DU branch matches. Preserve security if
|
||
// the new network supports it, otherwise force back to 'none'.
|
||
function onNetworkChange(next: string) {
|
||
if (next === 'hysteria') {
|
||
form.setFieldValue('streamSettings', hysteriaStreamSlice());
|
||
return;
|
||
}
|
||
const currentSecurity = form.getFieldValue(['streamSettings', 'security']) ?? 'none';
|
||
const stillAllowed = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
|
||
const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
|
||
const newSecurity =
|
||
currentSecurity === 'tls' && !stillAllowed
|
||
? 'none'
|
||
: currentSecurity === 'reality' && !stillReality
|
||
? 'none'
|
||
: currentSecurity;
|
||
form.setFieldValue('streamSettings', { ...newStreamSlice(next), security: newSecurity });
|
||
}
|
||
|
||
function onXmuxToggle(checked: boolean) {
|
||
if (!checked) return;
|
||
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
|
||
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
||
if (hasValues) return;
|
||
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
|
||
}
|
||
|
||
const duplicateTag = useMemo(() => {
|
||
const myTag = tag.trim();
|
||
if (!myTag) return false;
|
||
if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
|
||
return (existingTags || []).includes(myTag);
|
||
}, [tag, existingTags, isEdit, outboundProp]);
|
||
|
||
// Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
|
||
// any edits into form state. When entering JSON tab, snapshot current
|
||
// form values so the user sees the live shape.
|
||
function applyJsonToForm(): boolean {
|
||
if (!jsonDirty) return true;
|
||
const raw = jsonText.trim();
|
||
if (!raw) return true;
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
||
} catch (e) {
|
||
messageApi.error(`JSON: ${(e as Error).message}`);
|
||
return false;
|
||
}
|
||
const next = rawOutboundToFormValues(parsed);
|
||
form.resetFields();
|
||
form.setFieldsValue(next);
|
||
setJsonDirty(false);
|
||
return true;
|
||
}
|
||
|
||
function switchTab(key: string) {
|
||
if (typeof document !== 'undefined') {
|
||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||
}
|
||
setActiveKey(key);
|
||
}
|
||
|
||
function onTabChange(key: string) {
|
||
if (key === '2') {
|
||
const values = form.getFieldsValue(true) as OutboundFormValues;
|
||
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
|
||
setJsonDirty(false);
|
||
switchTab(key);
|
||
return;
|
||
}
|
||
if (key === '1' && activeKey === '2') {
|
||
if (!applyJsonToForm()) return;
|
||
}
|
||
switchTab(key);
|
||
}
|
||
|
||
async function onOk() {
|
||
let values: OutboundFormValues;
|
||
if (activeKey === '2') {
|
||
const raw = jsonText.trim();
|
||
if (!raw) return;
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
||
} catch (e) {
|
||
messageApi.error(`JSON: ${(e as Error).message}`);
|
||
return;
|
||
}
|
||
values = rawOutboundToFormValues(parsed);
|
||
form.resetFields();
|
||
form.setFieldsValue(values);
|
||
setJsonDirty(false);
|
||
} else {
|
||
try {
|
||
await form.validateFields();
|
||
} catch {
|
||
return;
|
||
}
|
||
values = form.getFieldsValue(true) as OutboundFormValues;
|
||
}
|
||
const tagValue = (values.tag ?? '').trim();
|
||
if (!tagValue) {
|
||
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
|
||
return;
|
||
}
|
||
const isDuplicateTag = (existingTags || []).includes(tagValue)
|
||
&& !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
|
||
if (isDuplicateTag) {
|
||
messageApi.error('Tag already used by another outbound');
|
||
return;
|
||
}
|
||
onConfirm(formValuesToWirePayload(values));
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{messageContextHolder}
|
||
<Modal
|
||
open={open}
|
||
title={title}
|
||
okText={okText}
|
||
cancelText={t('close')}
|
||
mask={{ closable: false }}
|
||
width={780}
|
||
onOk={onOk}
|
||
onCancel={onClose}
|
||
destroyOnHidden
|
||
>
|
||
<Form
|
||
form={form}
|
||
colon={false}
|
||
labelCol={{ md: { span: 8 } }}
|
||
wrapperCol={{ md: { span: 14 } }}
|
||
onValuesChange={onValuesChange}
|
||
>
|
||
<Tabs
|
||
activeKey={activeKey}
|
||
onChange={onTabChange}
|
||
items={[
|
||
{
|
||
key: '1',
|
||
label: t('pages.xray.basicTemplate'),
|
||
children: (
|
||
<>
|
||
<Form.Item
|
||
label={t('protocol')}
|
||
name="protocol"
|
||
rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
|
||
>
|
||
<Select options={PROTOCOL_OPTIONS} />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label={t('pages.xray.outbound.tag')}
|
||
name="tag"
|
||
validateStatus={duplicateTag ? 'warning' : undefined}
|
||
help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined}
|
||
rules={[
|
||
{ required: true, message: t('pages.xray.outboundForm.tagRequired') },
|
||
]}
|
||
>
|
||
<Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
|
||
</Form.Item>
|
||
|
||
<Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
|
||
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
|
||
</Form.Item>
|
||
|
||
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
|
||
{protocol === 'vmess' && <VmessFields />}
|
||
{protocol === 'vless' && <VlessFields />}
|
||
{protocol === 'trojan' && <TrojanFields />}
|
||
{protocol === 'shadowsocks' && <ShadowsocksFields />}
|
||
{protocol === 'http' && <HttpFields />}
|
||
{protocol === 'socks' && <SocksFields />}
|
||
|
||
{protocol === 'loopback' && <LoopbackFields />}
|
||
{protocol === 'blackhole' && <BlackholeFields />}
|
||
{protocol === 'dns' && <DnsFields />}
|
||
|
||
{protocol === 'freedom' && <FreedomFields form={form} />}
|
||
|
||
{protocol === 'vless' && (
|
||
<Form.Item shouldUpdate noStyle>
|
||
{() => {
|
||
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
|
||
if (!reverseTag) return null;
|
||
const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
|
||
enabled?: boolean;
|
||
};
|
||
return (
|
||
<>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.reverseSniffing')}
|
||
name={['settings', 'reverseSniffing', 'enabled']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
{sniff.enabled && (
|
||
<>
|
||
<Form.Item
|
||
wrapperCol={{ md: { span: 14, offset: 8 } }}
|
||
name={['settings', 'reverseSniffing', 'destOverride']}
|
||
>
|
||
<Select
|
||
mode="multiple"
|
||
className="sniffing-options"
|
||
options={Object.entries(SNIFFING_OPTION).map(([k, v]) => ({
|
||
value: v,
|
||
label: k,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.sniffingMetadataOnly')}
|
||
name={['settings', 'reverseSniffing', 'metadataOnly']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.sniffingRouteOnly')}
|
||
name={['settings', 'reverseSniffing', 'routeOnly']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.sniffingIpsExcluded')}
|
||
name={['settings', 'reverseSniffing', 'ipsExcluded']}
|
||
>
|
||
<Select
|
||
mode="tags"
|
||
tokenSeparators={[',']}
|
||
placeholder="IP/CIDR/geoip:*"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.sniffingDomainsExcluded')}
|
||
name={['settings', 'reverseSniffing', 'domainsExcluded']}
|
||
>
|
||
<Select
|
||
mode="tags"
|
||
tokenSeparators={[',']}
|
||
placeholder="domain:*"
|
||
/>
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}}
|
||
</Form.Item>
|
||
)}
|
||
|
||
{protocol === 'wireguard' && <WireguardFields form={form} />}
|
||
|
||
{streamAllowed && network && (
|
||
<>
|
||
<Form.Item
|
||
label={t('transmission')}
|
||
name={['streamSettings', 'network']}
|
||
>
|
||
<Select
|
||
value={network}
|
||
onChange={onNetworkChange}
|
||
options={
|
||
protocol === 'hysteria'
|
||
? [HYSTERIA_NETWORK_OPTION]
|
||
: NETWORK_OPTIONS
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
|
||
{network === 'tcp' && <RawForm form={form} />}
|
||
|
||
{network === 'kcp' && <KcpForm />}
|
||
|
||
{network === 'ws' && <WsForm />}
|
||
|
||
{network === 'grpc' && <GrpcForm />}
|
||
|
||
{network === 'httpupgrade' && <HttpUpgradeForm />}
|
||
|
||
{network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />}
|
||
|
||
{network === 'hysteria' && (
|
||
<>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.version')}
|
||
name={['streamSettings', 'hysteriaSettings', 'version']}
|
||
>
|
||
<InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.authPassword')}
|
||
name={['streamSettings', 'hysteriaSettings', 'auth']}
|
||
>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.udpIdleTimeout')}
|
||
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
||
>
|
||
<InputNumber min={1} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<HysteriaMasqueradeForm form={form} />
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{tlsFlowAllowed && (
|
||
<Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
|
||
<Select
|
||
allowClear
|
||
placeholder={t('none')}
|
||
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
|
||
{/* Vision seed knobs only meaningful for the exact
|
||
xtls-rprx-vision flow, on TCP+(tls|reality). The
|
||
legacy class gated this on `canEnableVisionSeed()`
|
||
— same condition encoded inline here. */}
|
||
<Form.Item shouldUpdate noStyle>
|
||
{() => {
|
||
const flow =
|
||
(form.getFieldValue(['settings', 'flow']) ?? '') as string;
|
||
if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
|
||
return (
|
||
<>
|
||
<Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
|
||
<Space.Compact block>
|
||
{[900, 500, 900, 256].map((def, i) => (
|
||
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
|
||
<InputNumber min={1} style={{ width: '25%' }} />
|
||
</Form.Item>
|
||
))}
|
||
</Space.Compact>
|
||
</Form.Item>
|
||
</>
|
||
);
|
||
}}
|
||
</Form.Item>
|
||
|
||
{streamAllowed && network && (
|
||
<Form.Item label={t('security')}>
|
||
<Radio.Group
|
||
value={security}
|
||
buttonStyle="solid"
|
||
onChange={(e) => onSecurityChange(e.target.value as string)}
|
||
>
|
||
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
|
||
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
|
||
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
|
||
</Radio.Group>
|
||
</Form.Item>
|
||
)}
|
||
|
||
{security === 'tls' && tlsAllowed && <TlsForm />}
|
||
|
||
{security === 'reality' && realityAllowed && <RealityForm />}
|
||
|
||
{((streamAllowed && network) || !streamAllowed) && (
|
||
<Form.Item shouldUpdate noStyle>
|
||
{() => {
|
||
const hasSockopt = !!form.getFieldValue([
|
||
'streamSettings',
|
||
'sockopt',
|
||
]);
|
||
return (
|
||
<>
|
||
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
|
||
<Switch
|
||
checked={hasSockopt}
|
||
onChange={(checked) => {
|
||
form.setFieldValue(
|
||
['streamSettings', 'sockopt'],
|
||
checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
|
||
);
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
{hasSockopt && (
|
||
<>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.dialerProxy')}
|
||
name={['streamSettings', 'sockopt', 'dialerProxy']}
|
||
>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.wireguard.domainStrategy')}
|
||
name={['streamSettings', 'sockopt', 'domainStrategy']}
|
||
>
|
||
<Select
|
||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
|
||
value: v,
|
||
label: v,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.addressPortStrategy')}
|
||
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
|
||
>
|
||
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.keepAliveInterval')}
|
||
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
|
||
>
|
||
<InputNumber min={0} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.multipathTcp')}
|
||
name={['streamSettings', 'sockopt', 'tcpMptcp']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.penetrate')}
|
||
name={['streamSettings', 'sockopt', 'penetrate']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.markFwmark')}
|
||
name={['streamSettings', 'sockopt', 'mark']}
|
||
>
|
||
<InputNumber min={0} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.interface')}
|
||
name={['streamSettings', 'sockopt', 'interfaceName']}
|
||
>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label="TProxy"
|
||
name={['streamSettings', 'sockopt', 'tproxy']}
|
||
>
|
||
<Select
|
||
options={[
|
||
{ value: 'off', label: 'off' },
|
||
{ value: 'redirect', label: 'redirect' },
|
||
{ value: 'tproxy', label: 'tproxy' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.tcpCongestion')}
|
||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||
>
|
||
<Select
|
||
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
|
||
value: v,
|
||
label: v,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.ipv6Only')}
|
||
name={['streamSettings', 'sockopt', 'V6Only']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.acceptProxyProtocol')}
|
||
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
|
||
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
|
||
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.tcpMaxSeg')}
|
||
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||
>
|
||
<Select
|
||
mode="tags"
|
||
tokenSeparators={[',', ' ']}
|
||
placeholder="trusted-proxy.example,10.0.0.0/8"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item shouldUpdate noStyle>
|
||
{() => {
|
||
const he = form.getFieldValue([
|
||
'streamSettings', 'sockopt', 'happyEyeballs',
|
||
]);
|
||
const hasHe = he != null;
|
||
return (
|
||
<>
|
||
<Form.Item label="Happy Eyeballs">
|
||
<Switch
|
||
checked={hasHe}
|
||
onChange={(v) => {
|
||
form.setFieldValue(
|
||
['streamSettings', 'sockopt', 'happyEyeballs'],
|
||
v ? HappyEyeballsSchema.parse({}) : undefined,
|
||
);
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
{hasHe && (
|
||
<>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.tryDelayMs')}
|
||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 (disabled) — 250 recommended" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.prioritizeIPv6')}
|
||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.interleave')}
|
||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
|
||
>
|
||
<InputNumber min={1} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.inbounds.form.maxConcurrentTry')}
|
||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
|
||
>
|
||
<InputNumber min={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}}
|
||
</Form.Item>
|
||
<Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
|
||
{(fields, { add, remove }) => (
|
||
<>
|
||
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
|
||
<Button
|
||
type="dashed"
|
||
size="small"
|
||
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
|
||
>
|
||
+ {t('pages.inbounds.form.addCustomOption')}
|
||
</Button>
|
||
</Form.Item>
|
||
{fields.map((field) => (
|
||
<Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
|
||
<Form.Item name={[field.name, 'system']} noStyle>
|
||
<Select
|
||
placeholder="all"
|
||
allowClear
|
||
style={{ width: 100 }}
|
||
options={[
|
||
{ value: 'linux', label: 'linux' },
|
||
{ value: 'windows', label: 'windows' },
|
||
{ value: 'darwin', label: 'darwin' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name={[field.name, 'type']} noStyle>
|
||
<Select
|
||
style={{ width: 80 }}
|
||
options={[
|
||
{ value: 'int', label: 'int' },
|
||
{ value: 'str', label: 'str' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name={[field.name, 'level']} noStyle>
|
||
<Input placeholder="level (6=TCP)" style={{ width: 100 }} />
|
||
</Form.Item>
|
||
<Form.Item name={[field.name, 'opt']} noStyle>
|
||
<Input placeholder="opt (decimal)" style={{ width: 120 }} />
|
||
</Form.Item>
|
||
<Form.Item name={[field.name, 'value']} noStyle>
|
||
<Input placeholder="value" style={{ flex: 1 }} />
|
||
</Form.Item>
|
||
<Button danger onClick={() => remove(field.name)}>−</Button>
|
||
</Space.Compact>
|
||
))}
|
||
</>
|
||
)}
|
||
</Form.List>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}}
|
||
</Form.Item>
|
||
)}
|
||
|
||
<FinalMaskForm
|
||
name={['streamSettings', 'finalmask']}
|
||
network={network}
|
||
protocol={protocol}
|
||
form={form}
|
||
/>
|
||
|
||
{(() => {
|
||
const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
|
||
if (!isMuxAllowed(protocol, flow, network)) return null;
|
||
return (
|
||
<Form.Item shouldUpdate noStyle>
|
||
{() => {
|
||
const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
|
||
return (
|
||
<>
|
||
<Form.Item
|
||
label={t('pages.settings.mux')}
|
||
name={['mux', 'enabled']}
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
{muxEnabled && (
|
||
<>
|
||
<Form.Item
|
||
label={t('pages.settings.subFormats.concurrency')}
|
||
name={['mux', 'concurrency']}
|
||
>
|
||
<InputNumber min={-1} max={1024} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.settings.subFormats.xudpConcurrency')}
|
||
name={['mux', 'xudpConcurrency']}
|
||
>
|
||
<InputNumber min={-1} max={1024} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={t('pages.settings.subFormats.xudpUdp443')}
|
||
name={['mux', 'xudpProxyUDP443']}
|
||
>
|
||
<Select
|
||
options={['reject', 'allow', 'skip'].map((v) => ({
|
||
value: v,
|
||
label: v,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}}
|
||
</Form.Item>
|
||
);
|
||
})()}
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: '2',
|
||
label: 'JSON',
|
||
children: (
|
||
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
|
||
<Input.Search
|
||
value={linkInput}
|
||
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
|
||
enterButton="Import"
|
||
onChange={(e) => setLinkInput(e.target.value)}
|
||
onSearch={importLink}
|
||
/>
|
||
<JsonEditor
|
||
value={jsonText}
|
||
onChange={(next) => {
|
||
setJsonText(next);
|
||
setJsonDirty(true);
|
||
}}
|
||
minHeight="360px"
|
||
maxHeight="600px"
|
||
/>
|
||
</Space>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Form>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|