mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
d5ab84e8d5
* feat(amneziawg): add AmneziaWG as an outbound protocol - AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI - Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test - Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions - Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test - Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go - Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients) - Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress) - Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts) - Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks * fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys - Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns - Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound - Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection - Trim comment blocks exceeding 2 lines across modified files - Fix Storybook test execution on environments with POSIX locale Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
601 lines
22 KiB
TypeScript
601 lines
22 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Form, Input, InputNumber, Modal, Radio, Select, Space, Tabs, message } from 'antd';
|
|
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
|
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
|
import { JsonEditor } from '@/components/form';
|
|
import { Wireguard } from '@/utils';
|
|
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
|
|
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
|
|
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
|
|
import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form';
|
|
import {
|
|
canEnableReality,
|
|
canEnableStream,
|
|
canEnableTls,
|
|
canEnableTlsFlow,
|
|
} from '@/lib/xray/protocol-capabilities';
|
|
|
|
import {
|
|
FLOW_OPTIONS,
|
|
HYSTERIA_NETWORK_OPTION,
|
|
NETWORK_OPTIONS,
|
|
PROTOCOL_OPTIONS,
|
|
SERVER_PROTOCOLS,
|
|
TARGET_STRATEGY_OPTIONS,
|
|
} from './outbound-form-constants';
|
|
import {
|
|
applyNetworkChange,
|
|
buildAddModeValues,
|
|
hysteriaStreamSlice,
|
|
newStreamSlice,
|
|
} from './outbound-form-helpers';
|
|
import {
|
|
BlackholeFields,
|
|
DnsFields,
|
|
FreedomFields,
|
|
HttpFields,
|
|
LoopbackFields,
|
|
ServerTarget,
|
|
ShadowsocksFields,
|
|
SocksFields,
|
|
TrojanFields,
|
|
VlessFields,
|
|
VmessFields,
|
|
WireguardFields,
|
|
} from './protocols';
|
|
import { AmneziawgFields } from './protocols';
|
|
import {
|
|
GrpcForm,
|
|
HttpUpgradeForm,
|
|
HysteriaForm,
|
|
KcpForm,
|
|
MuxForm,
|
|
RawForm,
|
|
SockoptForm,
|
|
WsForm,
|
|
XhttpForm,
|
|
} from './transport';
|
|
import { RealityForm, TlsForm } from './security';
|
|
import './OutboundFormModal.css';
|
|
|
|
type StreamValue = OutboundFormValues['streamSettings'];
|
|
|
|
interface OutboundFormModalProps {
|
|
open: boolean;
|
|
outbound: Record<string, unknown> | null;
|
|
existingTags: string[];
|
|
dialerProxyTags?: string[];
|
|
onClose: () => void;
|
|
onConfirm: (outbound: Record<string, unknown>) => void;
|
|
}
|
|
|
|
export default function OutboundFormModal({
|
|
open,
|
|
outbound: outboundProp,
|
|
existingTags,
|
|
dialerProxyTags,
|
|
onClose,
|
|
onConfirm,
|
|
}: OutboundFormModalProps) {
|
|
const { t } = useTranslation();
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
|
|
const [activeKey, setActiveKey] = useState('1');
|
|
const [jsonText, setJsonText] = useState('');
|
|
const [jsonDirty, setJsonDirty] = useState(false);
|
|
const [linkInput, setLinkInput] = useState('');
|
|
|
|
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');
|
|
|
|
const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
|
|
const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
|
|
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ??
|
|
'') as string;
|
|
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ??
|
|
'none') as string;
|
|
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
|
|
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
|
|
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as
|
|
| string
|
|
| undefined;
|
|
|
|
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 } });
|
|
|
|
/*
|
|
* 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 = methods.getValues('tag');
|
|
if (!parsed.tag && currentTag) parsed.tag = currentTag;
|
|
const next = rawOutboundToFormValues(parsed);
|
|
methods.reset(next);
|
|
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
|
|
setJsonDirty(false);
|
|
setLinkInput('');
|
|
messageApi.success('Link imported successfully');
|
|
switchTab('1');
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const initial = outboundProp ? rawOutboundToFormValues(outboundProp) : buildAddModeValues();
|
|
methods.reset(initial);
|
|
setActiveKey('1');
|
|
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
|
|
setJsonDirty(false);
|
|
}, [open, outboundProp, methods]);
|
|
|
|
useEffect(() => {
|
|
if (!streamAllowed) return;
|
|
/*
|
|
* Wireguard dials its own UDP — only finalmask/sockopt apply, never a
|
|
* transport. Don't seed network 'tcp'; clear a leftover one (from a
|
|
* protocol switch) so the transmission/security blocks stay hidden.
|
|
*/
|
|
if (protocol === 'wireguard') {
|
|
if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
|
|
return;
|
|
}
|
|
if (network) return;
|
|
methods.setValue('streamSettings', {
|
|
...newStreamSlice('tcp'),
|
|
security: 'none',
|
|
} as StreamValue);
|
|
}, [streamAllowed, network, protocol, methods]);
|
|
|
|
useEffect(() => {
|
|
if (protocol !== 'hysteria') return;
|
|
if (network === 'hysteria' && security === 'tls') return;
|
|
const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
|
const slice = hysteriaStreamSlice();
|
|
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
|
|
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
|
|
methods.setValue('streamSettings', slice as StreamValue);
|
|
}, [protocol, network, security, methods]);
|
|
|
|
useEffect(() => {
|
|
if (protocol !== 'wireguard') return;
|
|
const sk = (wgSecretKey ?? '').trim();
|
|
if (!sk) {
|
|
methods.setValue('settings.pubKey', '');
|
|
return;
|
|
}
|
|
try {
|
|
const { publicKey } = Wireguard.generateKeypair(sk);
|
|
methods.setValue('settings.pubKey', publicKey);
|
|
} catch {
|
|
methods.setValue('settings.pubKey', '');
|
|
}
|
|
}, [protocol, wgSecretKey, methods]);
|
|
|
|
useEffect(() => {
|
|
/* eslint-disable-next-line react-hooks/incompatible-library */
|
|
const sub = methods.watch((_value, { name, type }) => {
|
|
if (name !== 'protocol' || type !== 'change') return;
|
|
const nextProtocol = methods.getValues('protocol');
|
|
const next = rawOutboundToFormValues({ protocol: nextProtocol });
|
|
methods.setValue('settings', next.settings);
|
|
if (nextProtocol === 'hysteria') {
|
|
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
|
|
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
|
|
methods.setValue('streamSettings', {
|
|
...newStreamSlice('tcp'),
|
|
security: 'none',
|
|
} as StreamValue);
|
|
}
|
|
});
|
|
return () => sub.unsubscribe();
|
|
}, [methods]);
|
|
|
|
function onSecurityChange(next: string) {
|
|
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
|
const cleaned = { ...stream };
|
|
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;
|
|
methods.setValue('streamSettings', cleaned as StreamValue);
|
|
}
|
|
|
|
/*
|
|
* 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) {
|
|
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
|
methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
|
|
}
|
|
|
|
function onXmuxToggle(checked: boolean) {
|
|
if (!checked) return;
|
|
const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
|
|
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
|
if (hasValues) return;
|
|
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_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);
|
|
methods.reset(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 = methods.getValues();
|
|
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);
|
|
methods.reset(values);
|
|
setJsonDirty(false);
|
|
} else {
|
|
if (!(await methods.trigger())) return;
|
|
values = methods.getValues();
|
|
}
|
|
const tagValue = (values.tag ?? '').trim();
|
|
if (!tagValue) {
|
|
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
|
|
return;
|
|
}
|
|
if (tagValue.startsWith('_bl_')) {
|
|
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
|
|
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
|
|
>
|
|
<FormProvider {...methods}>
|
|
<Form
|
|
colon={false}
|
|
labelCol={{ md: { span: 8 } }}
|
|
wrapperCol={{ md: { span: 14 } }}
|
|
labelWrap
|
|
>
|
|
<Tabs
|
|
activeKey={activeKey}
|
|
onChange={onTabChange}
|
|
items={[
|
|
{
|
|
key: '1',
|
|
label: t('pages.xray.basicTemplate'),
|
|
children: (
|
|
<>
|
|
<FormField
|
|
label={t('protocol')}
|
|
name="protocol"
|
|
rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
|
|
>
|
|
<Select id="protocol" options={PROTOCOL_OPTIONS} />
|
|
</FormField>
|
|
|
|
<Controller
|
|
control={methods.control}
|
|
name="tag"
|
|
rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
|
|
render={({ field, fieldState }) => {
|
|
const errorMessage = fieldState.error?.message
|
|
? t(fieldState.error.message, {
|
|
defaultValue: fieldState.error.message,
|
|
})
|
|
: '';
|
|
return (
|
|
<Form.Item
|
|
label={t('pages.xray.outbound.tag')}
|
|
required
|
|
validateStatus={
|
|
errorMessage ? 'error' : duplicateTag ? 'warning' : undefined
|
|
}
|
|
help={
|
|
errorMessage ||
|
|
(duplicateTag
|
|
? t('pages.xray.outboundForm.tagDuplicate')
|
|
: undefined)
|
|
}
|
|
>
|
|
<Input
|
|
value={field.value}
|
|
onChange={(e) => field.onChange(e.target.value)}
|
|
onBlur={field.onBlur}
|
|
ref={field.ref}
|
|
placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
|
|
/>
|
|
</Form.Item>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
|
|
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
|
|
</FormField>
|
|
|
|
<FormField
|
|
label={t('pages.xray.outbound.targetStrategy')}
|
|
name="targetStrategy"
|
|
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
|
|
>
|
|
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
|
|
</FormField>
|
|
|
|
{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 />}
|
|
|
|
{protocol === 'vless' && reverseTag && (
|
|
<Controller
|
|
control={methods.control}
|
|
name="settings.reverseSniffing"
|
|
render={({ field }) => (
|
|
<SniffingField
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{protocol === 'wireguard' && <WireguardFields />}
|
|
{protocol === 'amneziawg' && <AmneziawgFields />}
|
|
|
|
{streamAllowed && network && (
|
|
<>
|
|
<Form.Item label={t('transmission')}>
|
|
<Select
|
|
value={network}
|
|
onChange={onNetworkChange}
|
|
options={
|
|
protocol === 'hysteria'
|
|
? [HYSTERIA_NETWORK_OPTION]
|
|
: NETWORK_OPTIONS
|
|
}
|
|
/>
|
|
</Form.Item>
|
|
|
|
{network === 'tcp' && <RawForm />}
|
|
|
|
{network === 'kcp' && <KcpForm />}
|
|
|
|
{network === 'ws' && <WsForm />}
|
|
|
|
{network === 'grpc' && <GrpcForm />}
|
|
|
|
{network === 'httpupgrade' && <HttpUpgradeForm />}
|
|
|
|
{network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
|
|
|
|
{network === 'hysteria' && <HysteriaForm />}
|
|
</>
|
|
)}
|
|
|
|
{tlsFlowAllowed && (
|
|
<FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
|
|
<Select
|
|
allowClear
|
|
placeholder={t('none')}
|
|
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
|
|
/>
|
|
</FormField>
|
|
)}
|
|
|
|
{/* Vision seed knobs only meaningful for the exact
|
|
xtls-rprx-vision flow, on TCP+(tls|reality). */}
|
|
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
|
|
<>
|
|
<FormField
|
|
label={t('pages.xray.outboundForm.visionTestpre')}
|
|
name={['settings', 'testpre']}
|
|
>
|
|
<InputNumber min={0} style={{ width: '100%' }} />
|
|
</FormField>
|
|
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
|
|
<Space.Compact block>
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<FormField key={i} name={['settings', 'testseed', i]} noStyle>
|
|
<InputNumber min={1} style={{ width: '25%' }} />
|
|
</FormField>
|
|
))}
|
|
</Space.Compact>
|
|
</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 ||
|
|
protocol === 'wireguard') && (
|
|
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
|
|
)}
|
|
|
|
<Controller
|
|
control={methods.control}
|
|
name="streamSettings.finalmask"
|
|
render={({ field }) => (
|
|
<FinalMaskField
|
|
key={`${protocol}:${network}`}
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
network={network}
|
|
protocol={protocol}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<MuxForm protocol={protocol} network={network} />
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
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>
|
|
</FormProvider>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|