| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254 |
- 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,
- withoutClients,
- } 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,
- createMasqueTlsSettingsWithDefaultCert,
- } from '@/lib/xray/inbound-tls-defaults';
- import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque';
- 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 { XDriveStreamSettingsSchema } from '@/schemas/protocols/stream/xdrive';
- import { DateTimePicker } from '@/components/form';
- import { FinalMaskField } from '@/lib/xray/forms/fields';
- import XDriveForm from '@/lib/xray/forms/transport/XDriveForm';
- import './InboundFormModal.css';
- import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
- import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
- import {
- AmneziawgFields,
- HttpFields,
- HysteriaFields,
- MasqueSettingsFields,
- MasqueStreamFields,
- MixedFields,
- MtprotoFields,
- ShadowsocksFields,
- TuicFields,
- 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({});
- case 'xdrive':
- return XDriveStreamSettingsSchema.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.MASQUE &&
- protocol !== Protocols.WIREGUARD &&
- protocol !== Protocols.TUNNEL &&
- protocol !== Protocols.TUIC;
- 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
- ? withoutClients(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.MASQUE) {
- setV('streamSettings', {
- network: 'masque',
- security: 'tls',
- masqueSettings: { path: MASQUE_DEFAULT_PATH },
- tlsSettings: createMasqueTlsSettingsWithDefaultCert(),
- });
- } 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 === 'masque' || !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 () => {
- // settings.fallbacks has no bound field; shouldUnregister=false keeps it from
- // the reset object. An edit sends no clients: the server keeps the stored ones.
- const values = methods.getValues() as InboundFormValues;
- const parsed = InboundFormSchema.safeParse(values);
- if (!parsed.success) {
- const issues = parsed.error.issues;
- setActiveTab(tabForValidationPath(issues[0].path));
- 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, { omitClients: mode === 'edit' });
- 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 = (
- <>
- {mode === 'add' && (
- <FormField name="enable" label={t('enable')} valueProp="checked">
- <Switch id="inbound-enable" />
- </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 />
- </FormField>
- <FormField
- name="excludeFromSub"
- valueProp="checked"
- label={labelWithHint(
- t('pages.inbounds.form.excludeFromSub'),
- t('pages.inbounds.form.excludeFromSubHelp'),
- )}
- >
- <Switch />
- </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.TUIC && <TuicFields />}
- {protocol === Protocols.MASQUE && <MasqueSettingsFields />}
- {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',
- 'xdriveSettings',
- ];
- 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];
- }
- if (next === 'xdrive') {
- /* The core would read the inbound's TLS as the storage API's client TLS. */
- cleaned.security = 'none';
- delete cleaned.tlsSettings;
- delete cleaned.realitySettings;
- } else {
- delete cleaned.address;
- delete cleaned.port;
- }
- 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 };
- }
- }
- /* WebSocket needs HTTP/1.1 for its handshake; the TLS default
- * ALPN ['h2','http/1.1'] makes the server negotiate h2 (see #6782). */
- if (next === 'ws' && cleaned.security === 'tls') {
- const tls = (cleaned.tlsSettings as Record<string, unknown> | undefined) ?? {};
- cleaned.tlsSettings = { ...tls, alpn: ['http/1.1'] };
- }
- 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' },
- { value: 'xdrive', label: 'XDRIVE' },
- ]}
- />
- </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 />}
- {protocol === Protocols.MASQUE && <MasqueStreamFields />}
- {hasSelectableTransport && (
- <>
- {network === 'tcp' && <RawForm />}
- {network === 'ws' && <WsForm />}
- {network === 'grpc' && <GrpcForm />}
- {network === 'xhttp' && <XhttpForm />}
- {network === 'httpupgrade' && <HttpUpgradeForm />}
- {network === 'kcp' && <KcpForm />}
- {network === 'xdrive' && <XDriveForm />}
- </>
- )}
- {/* 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}
- side="server"
- />
- )}
- />
- )}
- </>
- );
- const tlsOk = canEnableTls({ protocol, streamSettings: { network, security } });
- const realityOk = canEnableReality({ protocol, streamSettings: { network, security } });
- const tlsOnly = protocol === Protocols.HYSTERIA || protocol === Protocols.MASQUE;
- 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,
- Protocols.TUIC,
- Protocols.MASQUE,
- ] 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>
- </>
- );
- }
|