Files
3x-ui/frontend/src/pages/inbounds/form/InboundFormModal.tsx
T
Amirmohammad Sadat Shokouhi 5a63d5d468 fix(mtproto): use hosts for public share links (#6369)
* fix(mtproto): use hosts for public share links

Generate MTProto subscription, client, copy, QR, and export links from managed Hosts so reverse-proxied public ports are advertised correctly. Migrate the redundant legacy custom share address into a Host and keep old imports compatible.

Closes #5126.

* fix(mtproto): keep host share links lossless and consistent

Address review on the MTProto hosts share-link change.

The migration no longer drops a legacy custom share address: an unrelated
(or disabled) Host stopped suppressing it, so only a Host already advertising
the same address does. An imported address now clears the same validation the
strict normalizer applies to every other protocol before it becomes a Host.

Panel and subscription agree on the endpoint a Host advertises: a portless host
string inherits the inbound port rather than the group's, and a port-only host
inherits the inbound address instead of emitting server=%3A8443.

LinksForClient prefers host endpoints for every protocol, the way getSubs and
inboundLinks already do, so the client-links API no longer ignores managed
hosts.

* fix(mtproto): migrate legacy share address past unusable hosts

The seeder skipped the conversion whenever any Host already carried the
address, including one that is disabled or excludes the raw sub type.
hostEndpoints drops those, so nothing advertised the address afterwards and
the marker committed with no way back. The duplicate check now mirrors that
same predicate.

UpdateInbound cleared a legacy MTProto shareAddr without the Host conversion
AddInbound runs, so re-applying an inbound definition through the API dropped
the public address silently. Both paths share one capture helper now.

Refresh the generated clients API reference for the summary reworded in the
previous commit.

* fix(inbounds): wait for the hosts list before building mtproto links

The page destructured only `hosts` from useHostsQuery, and that list reads
empty both while /panel/api/hosts/list is in flight and after it fails.
withMtprotoHostEndpoints then returns the inbound untouched, so Copy, QR and
Export advertise the internal listen port — the endpoint this branch exists to
replace. It is worse than not fixing it: the seeder has already moved a legacy
custom share address into a Host, so the fallback is the panel's own hostname
instead of the operator's address, and the Go generators reading the same rows
from the DB stay correct, so the two disagree for one inbound.

Fold the query into the page's existing readiness gate, the same way
useInbounds and HostsPage already consume that hook, so an empty list means
"no hosts" rather than "not loaded yet". The error branch fires only when
nothing is cached, so a refetch failing on window focus does not blank a page
whose host rows are still perfectly usable.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-08 15:36:57 +02:00

1192 lines
40 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
Alert,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Switch,
Tabs,
Tooltip,
message,
} from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types';
import { rawInboundToFormValues, formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { generateAwgObfuscation } from '@/lib/xray/amneziawg-obfuscation';
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
import {
canEnableReality,
canEnableSniffing,
canEnableStream,
canEnableTls,
isSS2022,
} from '@/lib/xray/protocol-capabilities';
import {
InboundDbFieldsSchema,
InboundFormBaseSchema,
InboundFormSchema,
type InboundFormValues,
} from '@/schemas/forms/inbound-form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { Protocols, TRAFFIC_RESETS } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
import { SniffingSchema } from '@/schemas/primitives/sniffing';
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws';
import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
import { DateTimePicker } from '@/components/form';
import { FinalMaskField } from '@/lib/xray/forms/fields';
import './InboundFormModal.css';
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
import {
AmneziawgFields,
HttpFields,
HysteriaFields,
MixedFields,
MtprotoFields,
ShadowsocksFields,
TunFields,
TunnelFields,
VlessFields,
WireguardFields,
} from './protocols';
import {
GrpcForm,
HttpUpgradeForm,
KcpForm,
RawForm,
SockoptForm,
WsForm,
XhttpForm,
} from './transport';
import { RealityForm, TlsForm } from './security';
import { useSecurityActions } from './useSecurityActions';
import { useInboundFallbacks } from './useInboundFallbacks';
import FallbacksCard from './FallbacksCard';
import SniffingTab from './SniffingTab';
import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
const labelWithHint = (label: string, hint: string) => (
<span>
{label}
<Tooltip title={hint}>
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
</Tooltip>
</span>
);
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_HOSTNAME_RE =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
function isValidShareAddrInput(value: string): boolean {
const v = value.trim();
if (v.length === 0) return true;
if (v.includes('://') || v.startsWith('//') || /[/?#@]/.test(v)) return false;
if (v.startsWith('[')) {
if (!v.endsWith(']')) return false;
try {
new URL(`http://${v}`);
return true;
} catch {
return false;
}
}
if (v.includes(':')) {
try {
new URL(`http://[${v}]`);
return true;
} catch {
return false;
}
}
return SHARE_ADDR_HOSTNAME_RE.test(v);
}
interface RhfValidationIssue {
path: PropertyKey[];
message: string;
}
function firstRhfValidationIssue(
value: unknown,
path: PropertyKey[] = [],
): RhfValidationIssue | null {
if (!value || typeof value !== 'object') return null;
const record = value as Record<string, unknown>;
// `type` is what marks a react-hook-form leaf FieldError; anything else is a group.
if ('type' in record) {
return { path, message: typeof record.message === 'string' ? record.message : '' };
}
for (const key of Object.keys(record)) {
const issue = firstRhfValidationIssue(record[key], [...path, key]);
if (issue) return issue;
}
return null;
}
function tabForValidationPath(path: PropertyKey[]): string {
if (path[0] === 'settings') return 'protocol';
if (path[0] === 'sniffing') return 'sniffing';
if (path[0] === 'streamSettings') {
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
return 'security';
return 'stream';
}
return 'basic';
}
interface InboundFormModalProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
mode: 'add' | 'edit';
dbInbound: DBInbound | null;
dbInbounds: DBInbound[];
availableNodes?: NodeRecord[];
availableNodesFetched?: boolean;
}
function buildAddModeValues(): InboundFormValues {
const settings = createDefaultInboundSettings('vless') ?? undefined;
return rawInboundToFormValues({
protocol: 'vless',
settings,
streamSettings: {
network: 'tcp',
security: 'none',
tcpSettings: TcpStreamSettingsSchema.parse({ header: { type: 'none' } }),
},
sniffing: SniffingSchema.parse({}),
port: RandomUtil.randomInteger(10000, 60000),
listen: '',
tag: '',
enable: true,
trafficReset: 'never',
});
}
/*
* Switching `network` swaps which per-network key (tcpSettings, wsSettings,
* grpcSettings, ...) appears on the wire. Seed each network's blob with its
* Zod schema defaults so every field inside the network sub-form has a
* defined starting value (KCP needs MTU=1350 etc., XHTTP needs the ""
* sentinels so the "Default" option shows instead of blank).
*/
function newStreamSlice(n: string): Record<string, unknown> {
switch (n) {
case 'tcp':
return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
case 'kcp':
return KcpStreamSettingsSchema.parse({});
case 'ws':
return WsStreamSettingsSchema.parse({});
case 'grpc':
return GrpcStreamSettingsSchema.parse({});
case 'httpupgrade':
return HttpUpgradeStreamSettingsSchema.parse({});
case 'xhttp':
return XHttpStreamSettingsSchema.parse({});
default:
return {};
}
}
export default function InboundFormModal({
open,
onClose,
onSaved,
mode,
dbInbound,
dbInbounds,
availableNodes,
availableNodesFetched = true,
}: InboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [modal, modalContextHolder] = Modal.useModal();
const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() });
const setV = methods.setValue as unknown as (name: string, value: unknown) => void;
const getV = methods.getValues as unknown as (name?: string) => unknown;
const control = methods.control;
const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
const [activeTab, setActiveTab] = useState('basic');
const {
fallbacks,
fallbackChildOptions,
loadFallbacks,
saveFallbacks,
addFallback,
updateFallback,
removeFallback,
moveFallback,
addAllFallbacks,
} = useInboundFallbacks(dbInbound, dbInbounds);
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
const protocol = (useWatch({ control, name: 'protocol' }) ?? '') as string;
const isNodeEligible = !!NODE_ELIGIBLE_PROTOCOLS[protocol];
/*
* The `node` share-address strategy only means something when the inbound can
* actually live on a node — otherwise the node address it would resolve to is
* always empty. Offer it only then; `listen`/`custom` work for local inbounds.
*/
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
const ssMethod = useWatch({ control, name: 'settings.method' });
const isSSWith2022 = isSS2022({
protocol,
settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
});
const mixedUdpOn = (useWatch({ control, name: 'settings.udp' }) ?? false) as boolean;
const network = (useWatch({ control, name: 'streamSettings.network' }) ?? '') as string;
const security = (useWatch({ control, name: 'streamSettings.security' }) ?? 'none') as string;
const streamEnabled = canEnableStream({ protocol });
const sniffingSupported = canEnableSniffing({ protocol });
/*
* Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
* user-selectable transport — their stream tab is just sockopt, which is all
* Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its
* own dedicated transport form. For all of these the RAW/mKCP/WS/... network
* picker and the per-network sub-forms are hidden.
*/
const hasSelectableTransport =
protocol !== Protocols.HYSTERIA &&
protocol !== Protocols.WIREGUARD &&
protocol !== Protocols.TUNNEL;
const wPort = useWatch({ control, name: 'port' });
const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
const wNodeId = useWatch({ control, name: 'nodeId' }) ?? null;
const shareAddrStrategy = useWatch({ control, name: 'shareAddrStrategy' }) ?? 'node';
const wTag = (useWatch({ control, name: 'tag' }) ?? '') as string;
const wSsNetwork = useWatch({ control, name: 'settings.network' });
const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never';
const autoTagRef = useRef(true);
const lastWrittenTagRef = useRef('');
const currentTagInput = (): InboundTagInput => ({
port: typeof wPort === 'number' ? wPort : 0,
nodeId: typeof wNodeId === 'number' ? wNodeId : null,
protocol,
streamSettings: { network },
settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn },
});
const isFallbackHost =
(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
network === 'tcp' &&
(security === 'tls' || security === 'reality');
const {
genRealityKeypair,
clearRealityKeypair,
genMldsa65,
clearMldsa65,
scanRealityTarget,
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
randomizeSpiderX,
getNewEchCert,
clearEchCert,
pinFromCert,
pinFromRemote,
setCertFromPanel,
clearCertFiles,
onSecurityChange,
} = useSecurityActions({
methods,
setSaving,
messageApi,
modal,
nodeId: typeof wNodeId === 'number' ? wNodeId : null,
setScanResult,
setScanning,
});
const toggleSockopt = (on: boolean) => {
if (on) {
setV('streamSettings.sockopt', SockoptStreamSettingsSchema.parse({}));
} else {
setV('streamSettings.sockopt', undefined);
}
};
const wgSecretKey = useWatch({ control, name: 'settings.secretKey' });
const wgPubKey =
typeof wgSecretKey === 'string' && wgSecretKey.length > 0
? Wireguard.generateKeypair(wgSecretKey).publicKey
: '';
const regenInboundWg = () => {
const kp = Wireguard.generateKeypair();
setV('settings.secretKey', kp.privateKey);
};
// AmneziaWG uses the same Curve25519 keys as WireGuard, just nested under
// settings.server instead of flat on settings — see amneziawg.ts. Unlike
// WireGuard's Xray-native inbound (which re-derives its public key at
// runtime and never stores one), AmneziaWG's server.publicKey is a real,
// persisted field the Go backend reads directly, so it must be kept in
// sync even when the user free-types a new private key instead of using
// the regenerate button.
const awgPrivateKey = useWatch({ control, name: 'settings.server.privateKey' });
const awgPubKey =
typeof awgPrivateKey === 'string' && awgPrivateKey.length > 0
? Wireguard.generateKeypair(awgPrivateKey).publicKey
: '';
useEffect(() => {
if (protocol === Protocols.AMNEZIAWG) {
setV('settings.server.publicKey', awgPubKey);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [awgPubKey, protocol]);
const regenInboundAwg = () => {
const kp = Wireguard.generateKeypair();
setV('settings.server.privateKey', kp.privateKey);
setV('settings.server.publicKey', kp.publicKey);
};
// Randomizes the AmneziaWG 3.1 obfuscation set client-side; the shared
// generator mirrors the Go backend's amneziawg.GenerateObfuscation31.
const regenInboundAwgObfuscation = () => {
const obf = generateAwgObfuscation();
for (const [field, value] of Object.entries(obf)) {
setV(`settings.server.${field}`, value);
}
};
const matchesVlessAuth = (
block: { id?: string; label?: string } | undefined | null,
authId: string,
) => {
if (block?.id === authId) return true;
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
if (authId === 'mlkem768')
return label.includes('mlkem768') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'x25519')
return label.includes('x25519') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'mlkem768_xorpub') return label.includes('mlkem768') && label.includes('xorpub');
if (authId === 'mlkem768_random') return label.includes('mlkem768') && label.includes('random');
if (authId === 'x25519_xorpub') return label.includes('x25519') && label.includes('xorpub');
if (authId === 'x25519_random') return label.includes('x25519') && label.includes('random');
return false;
};
const getNewVlessEnc = async (authId: string) => {
if (!authId) return;
setSaving(true);
try {
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
if (!msg?.success) return;
const obj = msg.obj as {
auths?: { decryption: string; encryption: string; label?: string; id?: string }[];
};
const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return;
setV('settings.decryption', block.decryption);
setV('settings.encryption', block.encryption);
} finally {
setSaving(false);
}
};
const clearVlessEnc = () => {
setV('settings.decryption', 'none');
setV('settings.encryption', 'none');
};
const vlessAuthKind = vlessEncryptionAuthKind(
typeof vlessEncryption === 'string' ? vlessEncryption : '',
);
const selectedVlessAuth = (() => {
const enc = typeof vlessEncryption === 'string' ? vlessEncryption : '';
if (!enc || enc === 'none') return 'None';
if (!vlessAuthKind) return t('pages.inbounds.vlessAuthCustom');
return t(VLESS_AUTH_LABEL_KEYS[vlessAuthKind]);
})();
useEffect(() => {
if (!open) return;
const initial =
mode === 'edit' && dbInbound ? rawInboundToFormValues(dbInbound) : buildAddModeValues();
methods.reset(initial);
setScanResult(null);
setActiveTab('basic');
const initialTag = (initial.tag ?? '') as string;
autoTagRef.current = isAutoInboundTag(initialTag, {
port: initial.port ?? 0,
nodeId: initial.nodeId ?? null,
protocol: initial.protocol,
streamSettings: (initial.streamSettings ?? {}) as Record<string, unknown>,
settings: (initial.settings ?? {}) as Record<string, unknown>,
});
lastWrittenTagRef.current = initialTag;
if (
mode === 'edit' &&
dbInbound &&
(dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
) {
loadFallbacks(dbInbound.id);
} else {
loadFallbacks(null);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, mode, dbInbound, methods]);
useEffect(() => {
if (!open) return;
if (wTag === lastWrittenTagRef.current) return;
autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, wTag]);
useEffect(() => {
if (!open || !autoTagRef.current) return;
const next = composeInboundTag(currentTagInput());
if (next !== ((getV('tag') as string | undefined) ?? '')) {
lastWrittenTagRef.current = next;
setV('tag', next);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
/*
* Keep the strategy value inside the visible option set: when `node` isn't
* offered (no node, or a protocol that can't deploy to one) fall back to
* `listen`, which yields the same link for a local inbound. Mirrors how the
* protocol reset drops a nodeId that no longer applies.
* Only downgrade once the inputs this decision depends on are settled, so a
* persisted `node` strategy is never clobbered by transient mount state (#5375).
*/
useEffect(() => {
if (!open) return;
if (!protocol) return;
const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
if (protocol === Protocols.MTPROTO) {
if (current !== 'listen') setV('shareAddrStrategy', 'listen');
if (getV('shareAddr')) setV('shareAddr', '');
return;
}
if (!availableNodesFetched) return;
if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
setV('shareAddrStrategy', 'listen');
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]);
/*
* Protocol picker reset cascades through the form — clearing the settings DU
* branch and dropping a nodeId that no longer applies. Only a real user
* change (type === 'change') triggers it; programmatic setValue (advanced
* JSON edits, open reset) must not, matching the legacy onValuesChange.
*/
useEffect(() => {
if (mode === 'edit') return;
/* eslint-disable-next-line react-hooks/incompatible-library */
const sub = methods.watch((_value, { name, type }) => {
if (name !== 'protocol' || type !== 'change') return;
const next = getV('protocol') as string;
const settings = createDefaultInboundSettings(next) ?? undefined;
setV('settings', settings);
if (!NODE_ELIGIBLE_PROTOCOLS[next]) {
setV('nodeId', null);
}
if (next !== Protocols.VLESS) {
setV('disableFlow', false);
}
if (next === Protocols.HYSTERIA) {
setV('streamSettings', {
network: 'hysteria',
security: 'tls',
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
finalmask: {
tcp: [],
udp: [
{
type: 'salamander',
settings: { password: RandomUtil.randomLowerAndNum(16) },
},
],
},
});
} else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
setV('streamSettings', { security: 'none' });
} else {
const current = getV('streamSettings') as { network?: string } | undefined;
if (current?.network === 'hysteria' || !current?.network) {
setV('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
}
}
});
return () => sub.unsubscribe();
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [mode, methods]);
const saveValues = async () => {
/*
* getValues() returns the entire form store, including settings.clients and
* settings.fallbacks which have no bound field (clients are managed via the
* standalone Client modal, not this inbound modal). With shouldUnregister
* false those pass-through sub-trees survive from the reset object, so the
* update wire payload never silently drops every client on save.
*/
const values = methods.getValues() as InboundFormValues;
const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) {
const issues = parsed.error.issues;
messageApi.error(formatInboundValidation(issues, values, t));
console.error(
'[InboundFormModal] schema validation failed:',
issues.map((issue) => formatInboundIssue(issue, values, t)),
);
return;
}
setSaving(true);
try {
const payload = formValuesToWirePayload(parsed.data);
const url =
mode === 'edit' && dbInbound
? `/panel/api/inbounds/update/${dbInbound.id}`
: '/panel/api/inbounds/add';
const msg = await HttpUtil.post(url, payload);
if (msg?.success) {
if (isFallbackHost) {
const obj = msg.obj as { id?: number; Id?: number } | null;
const masterId = mode === 'edit' ? dbInbound!.id : (obj?.id ?? obj?.Id ?? 0);
if (masterId) await saveFallbacks(masterId);
}
onSaved();
onClose();
}
} finally {
setSaving(false);
}
};
/*
* Field errors render inline, but every tab is force-rendered, so an error on
* a hidden tab looks like a dead Save button — jump to it and say what broke.
*/
const submit = methods.handleSubmit(saveValues, (errors) => {
const issue = firstRhfValidationIssue(errors);
if (!issue) return;
setActiveTab(tabForValidationPath(issue.path));
messageApi.error(formatInboundIssue(issue, methods.getValues(), t));
});
const title =
mode === 'edit' ? t('pages.inbounds.modifyInbound') : t('pages.inbounds.addInbound');
const okText = mode === 'edit' ? t('pages.clients.submitEdit') : t('create');
const basicTab = (
<>
<FormField name="enable" label={t('enable')} valueProp="checked">
<Switch />
</FormField>
<FormField name="remark" label={t('pages.inbounds.remark')}>
<Input />
</FormField>
{selectableNodes.length > 0 && isNodeEligible && (
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
<Select
showSearch
disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')}
allowClear
options={selectableNodes.map((n) => ({
value: n.id,
// Same rule as the clone target picker: only online is
// deployable (`unknown` = no heartbeat yet).
label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
disabled: n.status !== 'online',
}))}
/>
</FormField>
)}
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
</FormField>
<FormField
name="listen"
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
>
<Input placeholder={t('pages.inbounds.monitorDesc')} />
</FormField>
{protocol !== Protocols.MTPROTO && (
<>
<FormField
name="shareAddrStrategy"
label={labelWithHint(
t('pages.inbounds.form.shareAddrStrategy'),
t('pages.inbounds.form.shareAddrStrategyHelp'),
)}
>
<Select
options={SHARE_ADDR_STRATEGIES.filter(
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
).map((strategy) => ({
value: strategy,
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
}))}
/>
</FormField>
{shareAddrStrategy === 'custom' && (
<FormField
name="shareAddr"
label={labelWithHint(
t('pages.inbounds.form.shareAddr'),
t('pages.inbounds.form.shareAddrHelp'),
)}
rules={{
validate: (value) =>
isValidShareAddrInput(String(value ?? '')) ||
t('pages.inbounds.form.shareAddrHelp'),
}}
>
<Input placeholder="edge.example.com" />
</FormField>
)}
</>
)}
<FormField
name="subSortIndex"
label={labelWithHint(
t('pages.inbounds.form.subSortIndex'),
t('pages.inbounds.form.subSortIndexHelp'),
)}
>
<InputNumber min={1} />
</FormField>
{protocol === Protocols.VLESS && (
<FormField
name="disableFlow"
valueProp="checked"
label={labelWithHint(
t('pages.inbounds.form.disableFlow'),
t('pages.inbounds.form.disableFlowHelp'),
)}
>
<Switch />
</FormField>
)}
<FormField
name="port"
label={t('pages.inbounds.port')}
rules={{ validate: rhfZodValidate(InboundFormBaseSchema.shape.port) }}
>
<InputNumber min={isUdsListen ? 0 : 1} max={65535} />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.inbounds.meansNoLimit')}>
{t('pages.inbounds.totalFlow')}
</Tooltip>
}
>
<InputNumber
value={wTotal ? Math.round((wTotal / SizeFormatter.ONE_GB) * 100) / 100 : 0}
min={0}
step={1}
onChange={(v) => {
const bytes = NumberFormatter.toFixed((Number(v) || 0) * SizeFormatter.ONE_GB, 0);
setV('total', bytes);
}}
/>
</Form.Item>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<Select
options={TRAFFIC_RESETS.map((r) => ({
value: r,
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
}))}
/>
</FormField>
{trafficReset === 'monthly' && (
<FormField
name="trafficResetDay"
label={t('pages.inbounds.periodicTrafficResetDay')}
rules={{ validate: rhfZodValidate(InboundDbFieldsSchema.shape.trafficResetDay) }}
>
<InputNumber min={1} max={31} />
</FormField>
)}
<Form.Item
label={
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
{t('pages.inbounds.expireDate')}
</Tooltip>
}
>
<DateTimePicker
value={wExpiry > 0 ? dayjs(wExpiry) : null}
onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
/>
</Form.Item>
</>
);
const fallbacksCard = (
<FallbacksCard
fallbacks={fallbacks}
fallbackChildOptions={fallbackChildOptions}
addFallback={addFallback}
updateFallback={updateFallback}
removeFallback={removeFallback}
moveFallback={moveFallback}
addAllFallbacks={addAllFallbacks}
/>
);
const protocolTab = (
<>
{protocol === Protocols.WIREGUARD && (
<WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />
)}
{protocol === Protocols.AMNEZIAWG && (
<AmneziawgFields
awgPubKey={awgPubKey}
regenInboundAwg={regenInboundAwg}
regenInboundAwgObfuscation={regenInboundAwgObfuscation}
/>
)}
{protocol === Protocols.TUN && <TunFields />}
{protocol === Protocols.TUNNEL && <TunnelFields />}
{protocol === Protocols.HTTP && <HttpFields />}
{protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
{protocol === Protocols.MTPROTO && <MtprotoFields />}
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields isSSWith2022={isSSWith2022} />}
{protocol === Protocols.VLESS && (
<VlessFields
saving={saving}
selectedVlessAuth={selectedVlessAuth}
vlessAuthKind={vlessAuthKind}
network={network}
security={security}
getNewVlessEnc={getNewVlessEnc}
clearVlessEnc={clearVlessEnc}
/>
)}
{isFallbackHost && fallbacksCard}
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) &&
network === 'tcp' &&
!isFallbackHost && (
<Alert
className="mt-12"
type="info"
showIcon
title={t('pages.inbounds.fallbacks.needsTls')}
/>
)}
</>
);
/*
* Switching `network` swaps which per-network key appears on the wire. Clear
* the old network's blob and seed the new one with schema defaults, plus the
* FinalMask mkcp-legacy UDP mask when moving to mKCP (removed otherwise).
*/
const onNetworkChange = (next: string) => {
const ALL = [
'tcpSettings',
'kcpSettings',
'wsSettings',
'grpcSettings',
'httpupgradeSettings',
'xhttpSettings',
];
const current = (getV('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, network: next };
for (const k of ALL) {
if (k !== `${next}Settings`) delete cleaned[k];
}
cleaned[`${next}Settings`] = newStreamSlice(next);
if (next === 'kcp') {
const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
const hasMkcp = udp.some((m) => {
const entry = m as { type?: string };
return entry?.type === 'mkcp-legacy';
});
if (!hasMkcp) {
cleaned.finalmask = {
...fm,
udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
};
}
} else {
const fm = cleaned.finalmask as Record<string, unknown> | undefined;
if (fm && Array.isArray(fm.udp)) {
const udp = (fm.udp as unknown[]).filter(
(m) => (m as { type?: string })?.type !== 'mkcp-legacy',
);
cleaned.finalmask = { ...fm, udp };
}
}
setV('streamSettings', cleaned);
};
const streamTab = (
<>
{hasSelectableTransport && (
<Form.Item label={t('transmission')}>
<Select
style={{ width: '75%' }}
value={network}
onChange={onNetworkChange}
options={[
{ value: 'tcp', label: 'RAW' },
{ value: 'kcp', label: 'mKCP' },
{ value: 'ws', label: 'WebSocket' },
{ value: 'grpc', label: 'gRPC' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
]}
/>
</Form.Item>
)}
{/* Inbound Hysteria stream sub-form. The transport for hysteria
isn't user-selectable (always 'hysteria'), so the network
dropdown is hidden above. */}
{protocol === Protocols.HYSTERIA && <HysteriaFields />}
{hasSelectableTransport && (
<>
{network === 'tcp' && <RawForm />}
{network === 'ws' && <WsForm />}
{network === 'grpc' && <GrpcForm />}
{network === 'xhttp' && <XhttpForm />}
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'kcp' && <KcpForm />}
</>
)}
{/* The legacy externalProxy section is replaced by the Hosts page; the
field is still parsed/rendered for backward compatibility but is no
longer editable here. */}
<SockoptForm toggleSockopt={toggleSockopt} network={network} />
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
its stream tab is just sockopt + TProxy. */}
{protocol !== Protocols.TUNNEL && (
<Controller
control={control}
name="streamSettings.finalmask"
render={({ field }) => (
<FinalMaskField
key={`${protocol}:${network}`}
value={field.value}
onChange={field.onChange}
network={network}
protocol={protocol}
/>
)}
/>
)}
</>
);
const tlsOk = canEnableTls({ protocol, streamSettings: { network, security } });
const realityOk = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsOnly = protocol === Protocols.HYSTERIA;
const securityTab = (
<>
<Form.Item label={t('pages.inbounds.securityTab')}>
<Radio.Group
value={security}
buttonStyle="solid"
disabled={!tlsOk}
onChange={(e) => onSecurityChange(e.target.value)}
>
{!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
<Radio.Button value="tls">TLS</Radio.Button>
{realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
{security === 'tls' && (
<TlsForm
saving={saving}
setCertFromPanel={setCertFromPanel}
clearCertFiles={clearCertFiles}
pinFromCert={pinFromCert}
pinFromRemote={pinFromRemote}
getNewEchCert={getNewEchCert}
clearEchCert={clearEchCert}
/>
)}
{security === 'reality' && (
<RealityForm
saving={saving}
scanning={scanning}
scanResult={scanResult}
scanRealityTarget={scanRealityTarget}
scanRealityCandidates={scanRealityCandidates}
applyRealityScanResult={applyRealityScanResult}
randomizeShortIds={randomizeShortIds}
randomizeSpiderX={randomizeSpiderX}
genRealityKeypair={genRealityKeypair}
clearRealityKeypair={clearRealityKeypair}
genMldsa65={genMldsa65}
clearMldsa65={clearMldsa65}
/>
)}
</>
);
const advancedTab = (
<div className="advanced-shell">
<div className="advanced-panel">
<div className="advanced-panel__header">
<div>
<div className="advanced-panel__title">{t('pages.inbounds.advanced.title')}</div>
<div className="advanced-panel__subtitle">{t('pages.inbounds.advanced.subtitle')}</div>
</div>
</div>
<Tabs
className="advanced-inner-tabs"
items={[
{
key: 'all',
label: t('pages.inbounds.advanced.all'),
children: (
<>
<div className="advanced-editor-meta">{t('pages.inbounds.advanced.allHelp')}</div>
<AdvancedAllEditor
streamEnabled={streamEnabled}
sniffingEnabled={sniffingSupported}
/>
</>
),
},
{
key: 'settings',
label: t('pages.inbounds.advanced.settings'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.settingsHelp')}{' '}
<code>{'{ settings: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
path="settings"
wrapKey="settings"
minHeight="320px"
maxHeight="540px"
/>
</>
),
},
...(streamEnabled
? [
{
key: 'stream',
label: t('pages.inbounds.advanced.stream'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.streamHelp')}{' '}
<code>{'{ streamSettings: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
path="streamSettings"
wrapKey="streamSettings"
minHeight="320px"
maxHeight="540px"
/>
</>
),
},
]
: []),
...(sniffingSupported
? [
{
key: 'sniffing',
label: t('pages.inbounds.advanced.sniffing'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.sniffingHelp')}{' '}
<code>{'{ sniffing: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
path="sniffing"
wrapKey="sniffing"
minHeight="240px"
maxHeight="420px"
/>
</>
),
},
]
: []),
]}
/>
</div>
</div>
);
const sniffingTab = <SniffingTab />;
return (
<>
{messageContextHolder}
{modalContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
confirmLoading={saving}
mask={{ closable: false }}
width={780}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'basic',
label: t('pages.xray.basicTemplate'),
children: basicTab,
forceRender: true,
},
...((
[
Protocols.VLESS,
Protocols.SHADOWSOCKS,
Protocols.HTTP,
Protocols.MIXED,
Protocols.TUNNEL,
Protocols.TUN,
Protocols.WIREGUARD,
Protocols.MTPROTO,
Protocols.AMNEZIAWG,
] as string[]
).includes(protocol) || isFallbackHost
? [
{
key: 'protocol',
label: t('pages.inbounds.protocol'),
children: protocolTab,
forceRender: true,
},
]
: []),
...(streamEnabled
? [
{
key: 'stream',
label: t('pages.inbounds.streamTab'),
children: streamTab,
forceRender: true,
},
...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL
? [
{
key: 'security',
label: t('pages.inbounds.securityTab'),
children: securityTab,
forceRender: true,
},
]
: []),
]
: []),
...(sniffingSupported
? [
{
key: 'sniffing',
label: t('pages.inbounds.sniffingTab'),
children: sniffingTab,
forceRender: true,
},
]
: []),
{
key: 'advanced',
label: t('pages.xray.advancedTemplate'),
children: advancedTab,
forceRender: true,
},
]}
/>
</Form>
</FormProvider>
</Modal>
</>
);
}