mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
Frontend dev tooling (Husky, lint-staged, MSW, Storybook) + full React Hook Form migration (#5859)
* chore(frontend): add husky + lint-staged pre-commit gate Wire a local pre-commit gate that runs eslint --fix on staged frontend TypeScript via lint-staged. Because the only package.json lives in frontend/ while the git root is one level up, the prepare script installs husky hooks at frontend/.husky from the repo root (cd .. && husky frontend/.husky), and the pre-commit hook cd's into frontend/ before invoking lint-staged so node_modules resolves. * test(frontend): add MSW request mocking Add Mock Service Worker so tests can exercise the real http-init.ts request pipeline (CSRF acquisition, 403 refetch-and-retry, body parsing) instead of only stubbing HttpUtil. A node setupServer is started for the vitest unit project with onUnhandledRequest bypass so the existing HttpUtil spies and 55 component tests are untouched; the browser worker is copied to public/ for Storybook and dev use. * chore(frontend): add Storybook + component stories Set up Storybook 10 on the React-Vite builder (compatible with the pinned Vite 8.1.3 and React 19). The preview decorator mirrors the vitest component harness: an Ant Design ConfigProvider with a light/dark toolbar toggle and an en-US i18next instance. main.ts neutralizes the app vite config bits that do not belong in a component workshop (the three-entry rollup input, renderBuiltUrl, and the shared dist outDir) so build-storybook can never clobber internal/web/dist. Seeds stories across the presentational library (viz, ui, clients, feedback). build-storybook is a local tool and is not wired into the CI gate. * feat(frontend): add React Hook Form primitives Introduce the shared RHF layer that AntD inputs bind through, ahead of migrating the forms off Ant Design's Form store: - FormField wraps a Controller in an Ant Design Form.Item shell, reconciling the value/onChange shapes of Input, Switch, InputNumber, Select and friends via normalizeAntdOnChange, with input/output transforms and Zod-issue-key error messages resolved through t(). - useZodForm wires zodResolver (Zod 4) with the AntD-matching modes (validate on submit, then live) and shouldUnregister false so hidden and unmounted-tab fields keep their values. - rhfZodValidate covers the rare per-field rule sites. Covered by a FormField test exercising normalization, transforms, and resolver error surfacing. * refactor(frontend): migrate Pattern-B leaf forms to React Hook Form Move the controlled-useState leaf forms onto RHF via the FormField primitive, keeping Ant Design components and each form's exact submit behaviour (same safeParse, same toast on the first Zod issue, same payload building): - clients: ClientBulkAdjustModal, BulkAddToGroupModal, ClientBulkAddModal - xray: RuleFormModal, BalancerFormModal, WarpModal, NordModal Multi-control widgets that don't fit a single input (inbound dual select, subId regen, expiry branches, the balancer tag warning) stay as explicit Controller/setValue. Derived visibility now reads live values through useWatch. FormField gains a required prop so migrated fields keep their required-asterisk affordance. Settings tabs are intentionally excluded: they are control-panel components that live-patch a parent AllSetting via SettingListItem, not Ant Design Form submit-forms. * refactor(frontend): migrate LoginPage to React Hook Form Replace the Ant Design Form store + antdRule per-field validation with useForm + FormField. The AntD Form stays as the layout/submit wrapper, now driving methods.handleSubmit(onSubmit) via onFinish. Username and password validate through rhfZodValidate(LoginFormSchema.shape.*); the two-factor field keeps its conditional required rule (only registered when 2FA is enabled). Submit posts the same values to /login. * refactor(frontend): migrate ClientFormModal to React Hook Form Move the client add/edit form off controlled useState onto RHF while preserving exact submit behaviour (same ClientFormSchema / ClientCreateFormSchema safeParse, same toast, same payload + attach/ detach diff + external-links build). expiryDate is stored as an epoch number (never a Dayjs) to survive RHF's value cloning, converted at the DateTimePicker boundary. externalLinks uses useFieldArray with stable ids. inboundIds and the derived show*/ss2022 visibility read live via useWatch. Space.Compact button-group widgets stay manual Controllers so the joined borders keep working. * refactor(frontend): migrate Node and DNS modals to React Hook Form Both are self-contained Pattern-A forms (no shared fragments). Replace Form.useForm with useForm + FormProvider, Form.useWatch with useWatch, setFieldValue with setValue, and partial validateFields([...]) with methods.trigger([...]). Per-field antdRule becomes rhfZodValidate rules; the Node scheme->tlsVerify cascade moves to FormField onAfterChange; the DNS domains/expectIPs/unexpectIPs string arrays are driven by useWatch + setValue. Submit runs through handleSubmit on the modal OK button, preserving each form's exact validation, payload build, and save/onConfirm behaviour. * refactor(frontend): migrate HostFormModal to React Hook Form The host external-proxy editor's outer form moves to useForm + FormProvider. Security/tab visibility reads via useWatch; the three json-form editors (HostMuxForm/HostSockoptForm/HostFinalMaskForm) are bound as value/onChange black boxes through a Controller (their own internal forms are unchanged). remark/inboundId keep their validation via rhfZodValidate; submit runs through handleSubmit and builds the same payload (isDisabled = !enable) and save call. * refactor(frontend): migrate OutboundFormModal + fragments to React Hook Form Move the outbound form cluster off Ant Design's Form store onto RHF. The parent uses useForm + FormProvider with a watch() subscription for the protocol reseed cascade and setValue-based network/security/xmux cascades; the JSON<->Basic bridge and the formValuesToWirePayload submit are preserved exactly. Every outbound transport/protocol/security fragment now binds through FormField/useWatch via context. The shared config editors stay untouched and are bound through small value/onChange adapters (src/lib/xray/forms/fields: FinalMaskField, SniffingField, SockoptCustomField) via Controller; HeaderMapEditor binds directly. The host json-form wrappers that reuse the outbound MuxForm/ SockoptForm (HostMuxForm, HostSockoptForm, OutboundSubtreeJsonForm) move to a local RHF provider to match. Outbound render/link tests pass unchanged. * refactor(frontend): migrate InboundFormModal + fragments to React Hook Form Move the inbound add/edit form (the largest form in the panel) and its transport/protocol/security fragments off Ant Design's Form store onto RHF, mirroring the outbound migration. The parent uses useForm + FormProvider with a watch() subscription for the protocol reseed cascade (type==='change' guard so programmatic resets don't reseed) and setValue-based network/security cascades; useSecurityActions drives the TLS/Reality keypair + scan through setValue. Hidden pass-through Form.Items are dropped (their values ride in the reset object and survive via shouldUnregister:false), so getValues() still returns the settings.clients subtree untouched. accounts / certificates / tun lists use useFieldArray; the shared FinalMask/Sniffing/Sockopt editors bind through the value/onChange adapters. Submit keeps the manual InboundFormSchema.safeParse + formatInboundValidation toast + formValuesToWirePayload exactly. The golden link/full fixtures pass byte-for-byte, confirming identical wire output. inbound-form-blocks test harness rewritten from a Form.useForm harness to an RHF provider. * refactor(frontend): retire antdRule; document the RHF form pattern All forms now build on React Hook Form, so the AntD-Form Zod adapter antdRule (src/utils/zodForm.ts) has no remaining callers — remove it. Update frontend/CLAUDE.md: forms use useZodForm + FormField from components/form/rhf with zodResolver/rhfZodValidate validation; AntD <Form> is layout-only; the shared FinalMask/Sniffing/Sockopt editors stay AntD islands wrapped as value/onChange adapters bound via a Controller. * chore(frontend): cover esbuild in the allowScripts allowlist esbuild (pulled in transitively by Vite/Vitest/Storybook) ships a postinstall that npm's allow-scripts flags as uncovered on every install. Its platform binary is delivered through the @esbuild/<platform> optionalDependencies, so the postinstall isn't needed here; deny it like the other entries to silence the warning. * fix(frontend): restore label layout in Sniffing/FinalMask field adapters The value/onChange adapters that wrap the shared SniffingFields and FinalMaskForm editors put them in their own isolated AntD Form, but that Form was missing the label layout the fields used to inherit from the inbound/outbound parent form. Their labels rendered full-width instead of the compact right-aligned column, so the Sniffing tab and the TCP Masks / QUIC Params sections looked broken. Give both adapter forms the same colon=false, labelCol/wrapperCol span 8/14, labelWrap layout. * ci: add least-privilege permissions to Docs CI workflow The docs-ci workflow had no explicit permissions block, so it inherited the repository default for GITHUB_TOKEN. The build job only checks out and builds the docs, so restrict it to contents: read, resolving the CodeQL actions/missing-workflow-permissions alert.
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
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';
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
InboundFormSchema,
|
||||
type InboundFormValues,
|
||||
} from '@/schemas/forms/inbound-form';
|
||||
import { antdRule } from '@/utils/zodForm';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
|
||||
@@ -50,7 +51,7 @@ 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 { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||
import { FinalMaskField } from '@/lib/xray/forms/fields';
|
||||
import './InboundFormModal.css';
|
||||
|
||||
import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
|
||||
@@ -85,7 +86,7 @@ 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.
|
||||
/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
|
||||
const labelWithHint = (label: string, hint: string) => (
|
||||
<span>
|
||||
{label}
|
||||
@@ -162,6 +163,25 @@ function buildAddModeValues(): InboundFormValues {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 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,
|
||||
@@ -174,7 +194,10 @@ export default function InboundFormModal({
|
||||
}: InboundFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [form] = Form.useForm<InboundFormValues>();
|
||||
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);
|
||||
@@ -191,41 +214,47 @@ export default function InboundFormModal({
|
||||
} = useInboundFallbacks(dbInbound, dbInbounds);
|
||||
|
||||
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
|
||||
const protocol = (Form.useWatch('protocol', form) ?? '') as string;
|
||||
const protocol = (useWatch({ control, name: 'protocol' }) ?? '') as string;
|
||||
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(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.
|
||||
/*
|
||||
* 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 = Form.useWatch(['settings', 'encryption'], form) ?? '';
|
||||
const ssMethod = Form.useWatch(['settings', 'method'], form);
|
||||
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 = Form.useWatch(['settings', 'udp'], form) ?? false;
|
||||
const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
|
||||
const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
|
||||
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.
|
||||
/*
|
||||
* 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 = Form.useWatch('port', form);
|
||||
const wListen = (Form.useWatch('listen', form) ?? '') as string;
|
||||
const wPort = useWatch({ control, name: 'port' });
|
||||
const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
|
||||
const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
|
||||
const wNodeId = Form.useWatch('nodeId', form) ?? null;
|
||||
const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node';
|
||||
const wTag = Form.useWatch('tag', form) ?? '';
|
||||
const wSsNetwork = Form.useWatch(['settings', 'network'], form);
|
||||
const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
|
||||
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 autoTagRef = useRef(true);
|
||||
const lastWrittenTagRef = useRef('');
|
||||
const currentTagInput = (): InboundTagInput => ({
|
||||
@@ -257,27 +286,24 @@ export default function InboundFormModal({
|
||||
setCertFromPanel,
|
||||
clearCertFiles,
|
||||
onSecurityChange,
|
||||
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
|
||||
} = useSecurityActions({ methods, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
|
||||
|
||||
|
||||
const toggleSockopt = (on: boolean) => {
|
||||
if (on) {
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'sockopt'],
|
||||
SockoptStreamSettingsSchema.parse({}),
|
||||
);
|
||||
setV('streamSettings.sockopt', SockoptStreamSettingsSchema.parse({}));
|
||||
} else {
|
||||
form.setFieldValue(['streamSettings', 'sockopt'], undefined);
|
||||
setV('streamSettings.sockopt', undefined);
|
||||
}
|
||||
};
|
||||
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
|
||||
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();
|
||||
form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
|
||||
setV('settings.secretKey', kp.privateKey);
|
||||
};
|
||||
|
||||
const matchesVlessAuth = (
|
||||
@@ -306,16 +332,16 @@ export default function InboundFormModal({
|
||||
};
|
||||
const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
|
||||
if (!block) return;
|
||||
form.setFieldValue(['settings', 'decryption'], block.decryption);
|
||||
form.setFieldValue(['settings', 'encryption'], block.encryption);
|
||||
setV('settings.decryption', block.decryption);
|
||||
setV('settings.encryption', block.encryption);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearVlessEnc = () => {
|
||||
form.setFieldValue(['settings', 'decryption'], 'none');
|
||||
form.setFieldValue(['settings', 'encryption'], 'none');
|
||||
setV('settings.decryption', 'none');
|
||||
setV('settings.encryption', 'none');
|
||||
};
|
||||
|
||||
const vlessAuthKind = vlessEncryptionAuthKind(
|
||||
@@ -333,8 +359,7 @@ export default function InboundFormModal({
|
||||
const initial = mode === 'edit' && dbInbound
|
||||
? rawInboundToFormValues(dbInbound)
|
||||
: buildAddModeValues();
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initial);
|
||||
methods.reset(initial);
|
||||
setScanResult(null);
|
||||
const initialTag = (initial.tag ?? '') as string;
|
||||
autoTagRef.current = isAutoInboundTag(initialTag, {
|
||||
@@ -355,77 +380,67 @@ export default function InboundFormModal({
|
||||
loadFallbacks(null);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, mode, dbInbound, form]);
|
||||
/* 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
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [open, wTag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !autoTagRef.current) return;
|
||||
const next = composeInboundTag(currentTagInput());
|
||||
if (next !== (form.getFieldValue('tag') ?? '')) {
|
||||
if (next !== ((getV('tag') as string | undefined) ?? '')) {
|
||||
lastWrittenTagRef.current = next;
|
||||
form.setFieldValue('tag', next);
|
||||
setV('tag', next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
/* 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):
|
||||
// - `availableNodesFetched`: an empty `availableNodes` during the async
|
||||
// /nodes/list fetch is a placeholder, not "no nodes".
|
||||
// - `protocol`: `Form.useWatch('protocol')` is briefly empty on the first
|
||||
// edit render before initialValues apply, which would momentarily make the
|
||||
// node option look unavailable.
|
||||
/*
|
||||
* 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 (!availableNodesFetched || !protocol) return;
|
||||
const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
|
||||
const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
|
||||
if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
|
||||
form.setFieldValue('shareAddrStrategy', 'listen');
|
||||
setV('shareAddrStrategy', 'listen');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]);
|
||||
|
||||
// Why: protocol picker reset cascades through the form — clearing the
|
||||
// settings DU branch and dropping a nodeId that no longer applies. The
|
||||
// legacy modal did this imperatively in onProtocolChange; here we hook
|
||||
// into AntD's onValuesChange and let setFieldValue keep the rest of
|
||||
// the form state intact.
|
||||
const onValuesChange = (changed: Partial<InboundFormValues>) => {
|
||||
/*
|
||||
* 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;
|
||||
if ('protocol' in changed && typeof changed.protocol === 'string') {
|
||||
const next = changed.protocol;
|
||||
/* 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;
|
||||
form.setFieldValue('settings', settings);
|
||||
setV('settings', settings);
|
||||
if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
|
||||
form.setFieldValue('nodeId', null);
|
||||
setV('nodeId', null);
|
||||
}
|
||||
// Hysteria uses its dedicated transport — force the network branch
|
||||
// so the stream tab renders the hysteria sub-form, not the leftover
|
||||
// tcpSettings from the previous protocol. When leaving hysteria,
|
||||
// snap back to TCP so the standard network selector has a valid
|
||||
// starting point.
|
||||
if (next === Protocols.HYSTERIA) {
|
||||
form.setFieldValue('streamSettings', {
|
||||
setV('streamSettings', {
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
|
||||
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
|
||||
// Hysteria2 needs an obfs wrapper on the FinalMask side; seed
|
||||
// it with salamander + a random password so the listener boots
|
||||
// with a usable default. Re-selecting Hysteria from another
|
||||
// protocol re-runs this and refreshes the password — that's
|
||||
// intentional, the form was already being reset.
|
||||
finalmask: {
|
||||
tcp: [],
|
||||
udp: [{
|
||||
@@ -435,37 +450,28 @@ export default function InboundFormModal({
|
||||
},
|
||||
});
|
||||
} else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
|
||||
// Wireguard and Tunnel (dokodemo-door) have no user-selectable
|
||||
// transport: wireguard is always a UDP listener, and tunnel only needs
|
||||
// `sockopt.tproxy` for its TProxy/redirect mode. Drop the leftover
|
||||
// network/transport slices so the stream tab doesn't render a TCP
|
||||
// sub-form and the wire payload carries no dead tcpSettings — the
|
||||
// sockopt section (with TProxy) stays available.
|
||||
form.setFieldValue('streamSettings', { security: 'none' });
|
||||
setV('streamSettings', { security: 'none' });
|
||||
} else {
|
||||
const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
|
||||
const current = getV('streamSettings') as { network?: string } | undefined;
|
||||
if (current?.network === 'hysteria' || !current?.network) {
|
||||
form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
|
||||
setV('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [mode, methods]);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Why getFieldsValue(true) instead of the validateFields return value:
|
||||
// rc-component/form's validateFields filters its output by REGISTERED
|
||||
// name paths. settings.clients and settings.fallbacks have no Form.Item
|
||||
// bound to them (clients are managed via the standalone Client modal,
|
||||
// not inside this inbound modal) — so validateFields would drop them
|
||||
// and the update wire payload would silently delete every client on
|
||||
// every save. getFieldsValue(true) returns the entire form store and
|
||||
// keeps those sub-trees intact.
|
||||
const values = form.getFieldsValue(true) as InboundFormValues;
|
||||
if (!(await methods.trigger())) return;
|
||||
/*
|
||||
* 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;
|
||||
@@ -509,24 +515,16 @@ export default function InboundFormModal({
|
||||
|
||||
const basicTab = (
|
||||
<>
|
||||
<Form.Item name="tag" hidden noStyle><Input /></Form.Item>
|
||||
<Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
|
||||
<Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
|
||||
<Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
|
||||
<Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
|
||||
<Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
|
||||
<Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
|
||||
|
||||
<Form.Item name="enable" label={t('enable')} valuePropName="checked">
|
||||
<FormField name="enable" label={t('enable')} valueProp="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item name="remark" label={t('pages.inbounds.remark')}>
|
||||
<FormField name="remark" label={t('pages.inbounds.remark')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
{selectableNodes.length > 0 && isNodeEligible && (
|
||||
<Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
|
||||
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
|
||||
<Select
|
||||
showSearch
|
||||
disabled={mode === 'edit'}
|
||||
@@ -538,21 +536,21 @@ export default function InboundFormModal({
|
||||
disabled: n.status === 'offline',
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<Form.Item name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
</Form.Item>
|
||||
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
<FormField
|
||||
name="listen"
|
||||
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
|
||||
>
|
||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
<FormField
|
||||
name="shareAddrStrategy"
|
||||
label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
|
||||
>
|
||||
@@ -564,38 +562,35 @@ export default function InboundFormModal({
|
||||
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
{shareAddrStrategy === 'custom' && (
|
||||
<Form.Item
|
||||
<FormField
|
||||
name="shareAddr"
|
||||
label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
|
||||
rules={[{
|
||||
validator: (_, value) => (
|
||||
isValidShareAddrInput(String(value ?? ''))
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(t('pages.inbounds.form.shareAddrHelp')))
|
||||
),
|
||||
}]}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
|
||||
}}
|
||||
>
|
||||
<Input placeholder="edge.example.com" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
<FormField
|
||||
name="subSortIndex"
|
||||
label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
<FormField
|
||||
name="port"
|
||||
label={t('pages.inbounds.port')}
|
||||
rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
|
||||
rules={{ validate: rhfZodValidate(InboundFormBaseSchema.shape.port) }}
|
||||
>
|
||||
<InputNumber min={isUdsListen ? 0 : 1} max={65535} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
@@ -604,41 +599,25 @@ export default function InboundFormModal({
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.total !== curr.total}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const totalBytes = (getFieldValue('total') as number) ?? 0;
|
||||
const totalGB = totalBytes
|
||||
? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
|
||||
: 0;
|
||||
return (
|
||||
<InputNumber
|
||||
value={totalGB}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={(v) => {
|
||||
const bytes = NumberFormatter.toFixed(
|
||||
(Number(v) || 0) * SizeFormatter.ONE_GB,
|
||||
0,
|
||||
);
|
||||
setFieldValue('total', bytes);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
<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>
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
<Select
|
||||
options={TRAFFIC_RESETS.map((r) => ({
|
||||
value: r,
|
||||
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
@@ -647,20 +626,10 @@ export default function InboundFormModal({
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const expiry = (getFieldValue('expiryTime') as number) ?? 0;
|
||||
return (
|
||||
<DateTimePicker
|
||||
value={expiry > 0 ? dayjs(expiry) : null}
|
||||
onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<DateTimePicker
|
||||
value={wExpiry > 0 ? dayjs(wExpiry) : null}
|
||||
onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
@@ -690,7 +659,7 @@ export default function InboundFormModal({
|
||||
|
||||
{protocol === Protocols.MTPROTO && <MtprotoFields />}
|
||||
|
||||
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
|
||||
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields isSSWith2022={isSSWith2022} />}
|
||||
|
||||
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
|
||||
|
||||
@@ -707,41 +676,19 @@ export default function InboundFormModal({
|
||||
</>
|
||||
);
|
||||
|
||||
// Switching `network` swaps which per-network key (tcpSettings,
|
||||
// wsSettings, grpcSettings, ...) appears on the wire. Clear the old
|
||||
// network's blob and seed the new one with the schema defaults so the
|
||||
// Form.Items inside it have valid initial values (KCP needs MTU=1350
|
||||
// etc., not empty strings).
|
||||
// Seed each network's settings blob with its Zod schema defaults so
|
||||
// every Form.Item inside the network sub-form has a defined starting
|
||||
// value. XHTTP in particular has ~20 fields (sessionIDPlacement,
|
||||
// seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
|
||||
// is the literal "" sentinel meaning "let xray-core pick its
|
||||
// default". Without seeding "", the Form.Item reads `undefined` and
|
||||
// the Select shows blank instead of the "Default (path)" option.
|
||||
const 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 {};
|
||||
}
|
||||
};
|
||||
/*
|
||||
* 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 = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
|
||||
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);
|
||||
// mKCP wants a UDP mask wrapper on the FinalMask side; seed it with
|
||||
// `mkcp-legacy` so the inbound boots with a sensible default
|
||||
// instead of unobfuscated mKCP traffic. The user can still edit or
|
||||
// clear the mask via the FinalMask section.
|
||||
if (next === 'kcp') {
|
||||
const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
|
||||
const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
|
||||
@@ -762,15 +709,16 @@ export default function InboundFormModal({
|
||||
cleaned.finalmask = { ...fm, udp };
|
||||
}
|
||||
}
|
||||
form.setFieldValue('streamSettings', cleaned);
|
||||
setV('streamSettings', cleaned);
|
||||
};
|
||||
|
||||
const streamTab = (
|
||||
<>
|
||||
{hasSelectableTransport && (
|
||||
<Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
|
||||
<Form.Item label={t('transmission')}>
|
||||
<Select
|
||||
style={{ width: '75%' }}
|
||||
value={network}
|
||||
onChange={onNetworkChange}
|
||||
options={[
|
||||
{ value: 'tcp', label: 'RAW' },
|
||||
@@ -786,12 +734,8 @@ export default function InboundFormModal({
|
||||
|
||||
{/* Inbound Hysteria stream sub-form. The transport for hysteria
|
||||
isn't user-selectable (always 'hysteria'), so the network
|
||||
dropdown is hidden above. Fields here mirror the legacy
|
||||
HysteriaStreamSettings inbound class: version is locked to 2,
|
||||
auth + udpIdleTimeout are required, masquerade is an optional
|
||||
sub-object that lets xray-core disguise the listener as an
|
||||
HTTP server when probed. */}
|
||||
{protocol === Protocols.HYSTERIA && <HysteriaFields form={form} />}
|
||||
dropdown is hidden above. */}
|
||||
{protocol === Protocols.HYSTERIA && <HysteriaFields />}
|
||||
|
||||
{hasSelectableTransport && (
|
||||
<>
|
||||
@@ -801,7 +745,7 @@ export default function InboundFormModal({
|
||||
|
||||
{network === 'grpc' && <GrpcForm />}
|
||||
|
||||
{network === 'xhttp' && <XhttpForm form={form} />}
|
||||
{network === 'xhttp' && <XhttpForm />}
|
||||
|
||||
{network === 'httpupgrade' && <HttpUpgradeForm />}
|
||||
|
||||
@@ -813,56 +757,45 @@ export default function InboundFormModal({
|
||||
field is still parsed/rendered for backward compatibility but is no
|
||||
longer editable here. */}
|
||||
|
||||
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
|
||||
<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 && (
|
||||
<FinalMaskForm
|
||||
name={['streamSettings', 'finalmask']}
|
||||
network={network as string}
|
||||
protocol={protocol}
|
||||
form={form}
|
||||
<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 name={['streamSettings', 'security']} hidden noStyle>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.securityTab')}>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.security !== curr.streamSettings?.security
|
||||
|| prev.streamSettings?.network !== curr.streamSettings?.network
|
||||
|| prev.protocol !== curr.protocol
|
||||
}
|
||||
<Radio.Group
|
||||
value={security}
|
||||
buttonStyle="solid"
|
||||
disabled={!tlsOk}
|
||||
onChange={(e) => onSecurityChange(e.target.value)}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
|
||||
const net = getFieldValue(['streamSettings', 'network']) ?? '';
|
||||
const proto = getFieldValue('protocol') ?? '';
|
||||
const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
|
||||
const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
|
||||
const tlsOnly = proto === Protocols.HYSTERIA;
|
||||
return (
|
||||
<Radio.Group
|
||||
value={sec}
|
||||
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>
|
||||
{!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' && (
|
||||
@@ -916,7 +849,7 @@ export default function InboundFormModal({
|
||||
<div className="advanced-editor-meta">
|
||||
{t('pages.inbounds.advanced.allHelp')}
|
||||
</div>
|
||||
<AdvancedAllEditor form={form} streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
|
||||
<AdvancedAllEditor streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -930,7 +863,6 @@ export default function InboundFormModal({
|
||||
<code>{'{ settings: { ... } }'}</code>.
|
||||
</div>
|
||||
<AdvancedSliceEditor
|
||||
form={form}
|
||||
path="settings"
|
||||
wrapKey="settings"
|
||||
minHeight="320px"
|
||||
@@ -950,7 +882,6 @@ export default function InboundFormModal({
|
||||
<code>{'{ streamSettings: { ... } }'}</code>.
|
||||
</div>
|
||||
<AdvancedSliceEditor
|
||||
form={form}
|
||||
path="streamSettings"
|
||||
wrapKey="streamSettings"
|
||||
minHeight="320px"
|
||||
@@ -971,7 +902,6 @@ export default function InboundFormModal({
|
||||
<code>{'{ sniffing: { ... } }'}</code>.
|
||||
</div>
|
||||
<AdvancedSliceEditor
|
||||
form={form}
|
||||
path="sniffing"
|
||||
wrapKey="sniffing"
|
||||
minHeight="240px"
|
||||
@@ -1004,50 +934,42 @@ export default function InboundFormModal({
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
colon={false}
|
||||
labelCol={{ sm: { span: 8 } }}
|
||||
wrapperCol={{ sm: { span: 14 } }}
|
||||
labelWrap
|
||||
onValuesChange={onValuesChange}
|
||||
>
|
||||
<Tabs items={[
|
||||
// forceRender on every tab so all Form.Items register at modal
|
||||
// open, not lazily on first visit. Without it, AntD's items API
|
||||
// lazy-mounts inactive tabs — their fields don't register, so
|
||||
// Form.useWatch on a parent path (e.g. 'sniffing') returns the
|
||||
// partial-view {} until the user touches the tab and the
|
||||
// inner Form.Item for `sniffing.enabled` registers.
|
||||
{ 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,
|
||||
] 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 },
|
||||
// Wireguard and Tunnel can't do TLS/Reality (canEnableTls is false), so
|
||||
// the security tab would only show a fully disabled radio.
|
||||
...(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 {...methods}>
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ sm: { span: 8 } }}
|
||||
wrapperCol={{ sm: { span: 14 } }}
|
||||
labelWrap
|
||||
>
|
||||
<Tabs 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,
|
||||
] 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form } from 'antd';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
import { SniffingField } from '@/lib/xray/forms/fields';
|
||||
|
||||
export default function SniffingTab() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
const { control } = useFormContext();
|
||||
return (
|
||||
<SniffingFields
|
||||
name={['sniffing']}
|
||||
form={form}
|
||||
enableLabel={t('enable')}
|
||||
<Controller
|
||||
control={control}
|
||||
name="sniffing"
|
||||
render={({ field }) => (
|
||||
<SniffingField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
enableLabel={t('enable')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Form, type FormInstance } from 'antd';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import {
|
||||
@@ -9,46 +8,44 @@ import {
|
||||
normalizeClients,
|
||||
dropLegacyOptionalEmpties,
|
||||
} from '@/lib/xray/inbound-form-adapter';
|
||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||
|
||||
// Sub-editor for one slice of the form (settings, streamSettings, sniffing).
|
||||
// Holds a local text buffer so the user can type freely; on every keystroke
|
||||
// we try to JSON.parse and forward the result to form state. Invalid JSON
|
||||
// is held in the buffer until the next valid moment — no panic on partial
|
||||
// input. The buffer seeds once on mount; the modal's destroyOnHidden makes
|
||||
// each open a fresh editor instance, so we don't need to re-sync on outer
|
||||
// form changes.
|
||||
/*
|
||||
* Sub-editor for one slice of the form (settings, streamSettings, sniffing).
|
||||
* Holds a local text buffer so the user can type freely; on every keystroke
|
||||
* we try to JSON.parse and forward the result to form state. Invalid JSON
|
||||
* is held in the buffer until the next valid moment — no panic on partial
|
||||
* input. The buffer seeds once on mount; the modal's destroyOnHidden makes
|
||||
* each open a fresh editor instance, so we don't need to re-sync on outer
|
||||
* form changes.
|
||||
*/
|
||||
export function AdvancedSliceEditor({
|
||||
form,
|
||||
path,
|
||||
wrapKey,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
}: {
|
||||
form: FormInstance<InboundFormValues>;
|
||||
path: NamePath;
|
||||
// When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
|
||||
// the JSON the user sees matches the wire shape's slice envelope (e.g.
|
||||
// `{ "settings": { ... } }`). Edits unwrap the outer key before writing
|
||||
// back to the form. Mirrors the legacy modal's wrappedConfigValue.
|
||||
path: string;
|
||||
/*
|
||||
* When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
|
||||
* the JSON the user sees matches the wire shape's slice envelope (e.g.
|
||||
* `{ "settings": { ... } }`). Edits unwrap the outer key before writing
|
||||
* back to the form. Mirrors the legacy modal's wrappedConfigValue.
|
||||
*/
|
||||
wrapKey?: string;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
}) {
|
||||
const { control, getValues, setValue } = useFormContext();
|
||||
|
||||
const serialize = (value: unknown): string => {
|
||||
const inner = value ?? {};
|
||||
return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
|
||||
};
|
||||
|
||||
// preserve: true so useWatch returns the full subtree from the form
|
||||
// store — without it, useWatch goes through getFieldsValue() which
|
||||
// filters out unregistered fields. Slices like `settings` would lose
|
||||
// their `clients` / `fallbacks` sub-trees because those aren't bound
|
||||
// to any Form.Item.
|
||||
const watched = Form.useWatch(path, { form, preserve: true });
|
||||
const watched = useWatch({ control, name: path });
|
||||
const lastEmitRef = useRef<string>('');
|
||||
const [text, setText] = useState(() => {
|
||||
const initial = serialize(form.getFieldValue(path));
|
||||
const initial = serialize(getValues(path));
|
||||
lastEmitRef.current = initial;
|
||||
return initial;
|
||||
});
|
||||
@@ -58,7 +55,7 @@ export function AdvancedSliceEditor({
|
||||
if (formStr === lastEmitRef.current) return;
|
||||
setText(formStr);
|
||||
lastEmitRef.current = formStr;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [watched, wrapKey]);
|
||||
|
||||
return (
|
||||
@@ -73,48 +70,47 @@ export function AdvancedSliceEditor({
|
||||
const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)[wrapKey] ?? {}
|
||||
: parsed;
|
||||
form.setFieldValue(path, toWrite);
|
||||
setValue(path, toWrite);
|
||||
lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
|
||||
} catch {
|
||||
// invalid JSON; keep buffer, don't push to form
|
||||
/* invalid JSON; keep buffer, don't push to form */
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The "All" editor shows the full inbound JSON in one editor: top-level
|
||||
// connection fields plus the three nested sub-objects (settings,
|
||||
// streamSettings, sniffing). Edits round-trip back to the form's slices,
|
||||
// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
|
||||
// works the same way as AdvancedSliceEditor: useWatch on the slices we
|
||||
// care about, lastEmitRef as the "we wrote this" guard.
|
||||
/*
|
||||
* The "All" editor shows the full inbound JSON in one editor: top-level
|
||||
* connection fields plus the three nested sub-objects (settings,
|
||||
* streamSettings, sniffing). Edits round-trip back to the form's slices,
|
||||
* mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
|
||||
* works the same way as AdvancedSliceEditor: useWatch on the slices we
|
||||
* care about, lastEmitRef as the "we wrote this" guard.
|
||||
*/
|
||||
export function AdvancedAllEditor({
|
||||
form,
|
||||
streamEnabled,
|
||||
sniffingEnabled,
|
||||
}: {
|
||||
form: FormInstance<InboundFormValues>;
|
||||
streamEnabled: boolean;
|
||||
sniffingEnabled: boolean;
|
||||
}) {
|
||||
// preserve: true — default useWatch returns only registered fields, so
|
||||
// sub-trees we never bound (settings.clients/fallbacks, sniffing
|
||||
// defaults, etc.) wouldn't show up. preserve switches the read to
|
||||
// getFieldsValue(true) which returns the full form store.
|
||||
const wListen = Form.useWatch('listen', { form, preserve: true });
|
||||
const wPort = Form.useWatch('port', { form, preserve: true });
|
||||
const wProtocol = Form.useWatch('protocol', { form, preserve: true });
|
||||
const wTag = Form.useWatch('tag', { form, preserve: true });
|
||||
const wSettings = Form.useWatch('settings', { form, preserve: true });
|
||||
const wSniffing = Form.useWatch('sniffing', { form, preserve: true });
|
||||
const wStream = Form.useWatch('streamSettings', { form, preserve: true });
|
||||
const { control, setValue } = useFormContext();
|
||||
const wListen = useWatch({ control, name: 'listen' });
|
||||
const wPort = useWatch({ control, name: 'port' });
|
||||
const wProtocol = useWatch({ control, name: 'protocol' });
|
||||
const wTag = useWatch({ control, name: 'tag' });
|
||||
const wSettings = useWatch({ control, name: 'settings' });
|
||||
const wSniffing = useWatch({ control, name: 'sniffing' });
|
||||
const wStream = useWatch({ control, name: 'streamSettings' });
|
||||
|
||||
const serialize = () => {
|
||||
// Apply the same prune/normalize as the wire payload so the JSON
|
||||
// shown here is what the panel actually POSTs (no empty defaults,
|
||||
// disabled sniffing as { enabled: false }, finalmask dropped when
|
||||
// there are no masks).
|
||||
/*
|
||||
* Apply the same prune/normalize as the wire payload so the JSON
|
||||
* shown here is what the panel actually POSTs (no empty defaults,
|
||||
* disabled sniffing as { enabled: false }, finalmask dropped when
|
||||
* there are no masks).
|
||||
*/
|
||||
const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>;
|
||||
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
|
||||
settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
|
||||
@@ -149,7 +145,7 @@ export function AdvancedAllEditor({
|
||||
if (formStr === lastEmitRef.current) return;
|
||||
setText(formStr);
|
||||
lastEmitRef.current = formStr;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]);
|
||||
|
||||
return (
|
||||
@@ -166,20 +162,20 @@ export function AdvancedAllEditor({
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
|
||||
if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen);
|
||||
if (typeof parsed.listen === 'string') setValue('listen', parsed.listen);
|
||||
if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) {
|
||||
form.setFieldValue('port', parsed.port);
|
||||
setValue('port', parsed.port);
|
||||
}
|
||||
if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol);
|
||||
if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag);
|
||||
if (typeof parsed.protocol === 'string') setValue('protocol', parsed.protocol);
|
||||
if (typeof parsed.tag === 'string') setValue('tag', parsed.tag);
|
||||
if (parsed.settings && typeof parsed.settings === 'object') {
|
||||
form.setFieldValue('settings', parsed.settings);
|
||||
setValue('settings', parsed.settings);
|
||||
}
|
||||
if (sniffingEnabled && parsed.sniffing && typeof parsed.sniffing === 'object') {
|
||||
form.setFieldValue('sniffing', parsed.sniffing);
|
||||
setValue('sniffing', parsed.sniffing);
|
||||
}
|
||||
if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
|
||||
form.setFieldValue('streamSettings', parsed.streamSettings);
|
||||
setValue('streamSettings', parsed.streamSettings);
|
||||
}
|
||||
lastEmitRef.current = next;
|
||||
}}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Space } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function AccountsList() {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'settings.accounts' });
|
||||
return (
|
||||
<Form.List name={['settings', 'accounts']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.accounts')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => add({
|
||||
user: RandomUtil.randomLowerAndNum(8),
|
||||
pass: RandomUtil.randomLowerAndNum(12),
|
||||
})}
|
||||
>
|
||||
<PlusOutlined /> {t('add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
{fields.map((field, idx) => (
|
||||
<Space.Compact key={field.key} className="mb-8" block>
|
||||
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||
<Form.Item name={[field.name, 'user']} noStyle>
|
||||
<Input placeholder={t('username')} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'pass']} noStyle>
|
||||
<Input placeholder={t('password')} />
|
||||
</Form.Item>
|
||||
<Button aria-label={t('remove')} onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.accounts')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => append({
|
||||
user: RandomUtil.randomLowerAndNum(8),
|
||||
pass: RandomUtil.randomLowerAndNum(12),
|
||||
})}
|
||||
>
|
||||
<PlusOutlined /> {t('add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
{fields.map((field, idx) => (
|
||||
<Space.Compact key={field.id} className="mb-8" block>
|
||||
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||
<FormField name={['settings', 'accounts', idx, 'user']} noStyle>
|
||||
<Input placeholder={t('username')} />
|
||||
</FormField>
|
||||
<FormField name={['settings', 'accounts', idx, 'pass']} noStyle>
|
||||
<Input placeholder={t('password')} />
|
||||
</FormField>
|
||||
<Button aria-label={t('remove')} onClick={() => remove(idx)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Switch } from 'antd';
|
||||
import { Switch } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import AccountsList from './accounts-list';
|
||||
|
||||
export default function HttpFields() {
|
||||
@@ -8,13 +9,13 @@ export default function HttpFields() {
|
||||
return (
|
||||
<>
|
||||
<AccountsList />
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['settings', 'allowTransparent']}
|
||||
label={t('pages.inbounds.form.allowTransparent')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,128 +1,124 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
|
||||
|
||||
export default function HysteriaFields({ form }: { form: FormInstance }) {
|
||||
export default function HysteriaFields() {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as
|
||||
| { type?: string }
|
||||
| undefined;
|
||||
const masqType = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade.type' }) as
|
||||
| string
|
||||
| undefined;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.version')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'version']}
|
||||
>
|
||||
<InputNumber min={2} max={2} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.udpIdleTimeout')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
||||
>
|
||||
<InputNumber min={2} max={600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH);
|
||||
return (
|
||||
<Switch
|
||||
checked={!!m}
|
||||
onChange={(checked) =>
|
||||
form.setFieldValue(
|
||||
MASQ_PATH,
|
||||
checked
|
||||
? {
|
||||
type: '', dir: '', url: '',
|
||||
rewriteHost: false, insecure: false,
|
||||
content: '', headers: {}, statusCode: 0,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
<Switch
|
||||
checked={!!masq}
|
||||
onChange={(checked) =>
|
||||
setValue(
|
||||
'streamSettings.hysteriaSettings.masquerade',
|
||||
checked
|
||||
? {
|
||||
type: '', dir: '', url: '',
|
||||
rewriteHost: false, insecure: false,
|
||||
content: '', headers: {}, statusCode: 0,
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
|
||||
if (!m) return null;
|
||||
return (
|
||||
{masq && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.type')}
|
||||
name={[...MASQ_PATH, 'type']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
{ value: 'file', label: 'file (serve directory)' },
|
||||
{ value: 'string', label: 'string (fixed body)' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
{masqType === 'proxy' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.type')}
|
||||
name={[...MASQ_PATH, 'type']}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.upstreamUrl')}
|
||||
name={[...MASQ_PATH, 'url']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
{ value: 'file', label: 'file (serve directory)' },
|
||||
{ value: 'string', label: 'string (fixed body)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{m.type === 'proxy' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.upstreamUrl')}
|
||||
name={[...MASQ_PATH, 'url']}
|
||||
>
|
||||
<Input placeholder="https://www.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.rewriteHost')}
|
||||
name={[...MASQ_PATH, 'rewriteHost']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.skipTlsVerify')}
|
||||
name={[...MASQ_PATH, 'insecure']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{m.type === 'file' && (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.directory')}
|
||||
name={[...MASQ_PATH, 'dir']}
|
||||
>
|
||||
<Input placeholder="/var/www/html" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{m.type === 'string' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.statusCode')}
|
||||
name={[...MASQ_PATH, 'statusCode']}
|
||||
>
|
||||
<InputNumber min={0} max={599} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.body')}
|
||||
name={[...MASQ_PATH, 'content']}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 3 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={[...MASQ_PATH, 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Input placeholder="https://www.example.com" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.rewriteHost')}
|
||||
name={[...MASQ_PATH, 'rewriteHost']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.skipTlsVerify')}
|
||||
name={[...MASQ_PATH, 'insecure']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
)}
|
||||
{masqType === 'file' && (
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.directory')}
|
||||
name={[...MASQ_PATH, 'dir']}
|
||||
>
|
||||
<Input placeholder="/var/www/html" />
|
||||
</FormField>
|
||||
)}
|
||||
{masqType === 'string' && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.statusCode')}
|
||||
name={[...MASQ_PATH, 'statusCode']}
|
||||
>
|
||||
<InputNumber min={0} max={599} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.body')}
|
||||
name={[...MASQ_PATH, 'content']}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 3 }} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={[...MASQ_PATH, 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Select, Switch } from 'antd';
|
||||
import { Input, Select, Switch } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import AccountsList from './accounts-list';
|
||||
|
||||
export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
|
||||
@@ -8,25 +9,25 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<AccountsList />
|
||||
<Form.Item name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
|
||||
<FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'noauth', label: 'noauth' },
|
||||
{ value: 'password', label: 'password' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'udp']}
|
||||
label="UDP"
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{mixedUdpOn && (
|
||||
<Form.Item name={['settings', 'ip']} label="UDP IP">
|
||||
<FormField name={['settings', 'ip']} label="UDP IP">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useOutboundTags } from '@/api/queries/useOutboundTags';
|
||||
|
||||
export default function MtprotoFields() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined;
|
||||
const { control } = useFormContext();
|
||||
const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as boolean | undefined;
|
||||
const { data: outboundTags } = useOutboundTags();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['settings', 'fakeTlsDomain']}
|
||||
label={t('pages.inbounds.form.fakeTlsDomain')}
|
||||
tooltip={t('pages.inbounds.form.mtprotoFakeTlsDomainHint')}
|
||||
>
|
||||
<Input placeholder="www.cloudflare.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'domainFronting', 'ip']}
|
||||
label={t('pages.inbounds.form.mtgDomainFrontingIp')}
|
||||
tooltip={t('pages.inbounds.form.mtgDomainFrontingHint')}
|
||||
>
|
||||
<Input placeholder="127.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
|
||||
</FormField>
|
||||
<FormField name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
|
||||
<InputNumber min={0} max={65535} placeholder="443" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'domainFronting', 'proxyProtocol']}
|
||||
label={t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'proxyProtocolListener']}
|
||||
label={t('pages.inbounds.form.mtgProxyProtocolListener')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}>
|
||||
</FormField>
|
||||
<FormField name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="prefer-ipv6"
|
||||
@@ -52,27 +54,27 @@ export default function MtprotoFields() {
|
||||
{ value: 'only-ipv4', label: 'only-ipv4' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
|
||||
</FormField>
|
||||
<FormField name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valueProp="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'throttleMaxConnections']}
|
||||
label={t('pages.inbounds.form.mtgThrottleMaxConnections')}
|
||||
tooltip={t('pages.inbounds.form.mtgThrottleMaxConnectionsHint')}
|
||||
>
|
||||
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'routeThroughXray']}
|
||||
label={t('pages.inbounds.form.mtgRouteThroughXray')}
|
||||
tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{routeThroughXray && (
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['settings', 'outboundTag']}
|
||||
label={t('pages.inbounds.form.mtgRouteOutbound')}
|
||||
tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
|
||||
@@ -83,22 +85,22 @@ export default function MtprotoFields() {
|
||||
placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
|
||||
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['settings', 'publicIpv4']}
|
||||
label={t('pages.inbounds.form.mtgPublicIpv4')}
|
||||
tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
|
||||
>
|
||||
<Input allowClear placeholder="1.2.3.4" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'publicIpv6']}
|
||||
label={t('pages.inbounds.form.mtgPublicIpv6')}
|
||||
tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
|
||||
>
|
||||
<Input allowClear placeholder="2001:db8::1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Select, Space, Switch, type FormInstance } from 'antd';
|
||||
import { Button, Form, Input, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
|
||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||
|
||||
interface ShadowsocksFieldsProps {
|
||||
form: FormInstance<InboundFormValues>;
|
||||
isSSWith2022: boolean;
|
||||
}
|
||||
|
||||
export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFieldsProps) {
|
||||
export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getValues, setValue } = useFormContext();
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['settings', 'method']} label={t('pages.inbounds.form.encryptionMethod')}>
|
||||
<FormField
|
||||
name={['settings', 'method']}
|
||||
label={t('pages.inbounds.form.encryptionMethod')}
|
||||
onAfterChange={(v) => {
|
||||
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
onChange={(v) => {
|
||||
form.setFieldValue(
|
||||
['settings', 'password'],
|
||||
RandomUtil.randomShadowsocksPassword(v as string),
|
||||
);
|
||||
}}
|
||||
options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{isSSWith2022 && (
|
||||
<Form.Item label={t('password')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={['settings', 'password']} noStyle>
|
||||
<FormField name={['settings', 'password']} noStyle>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
const method = form.getFieldValue(['settings', 'method']);
|
||||
form.setFieldValue(
|
||||
['settings', 'password'],
|
||||
const method = getValues('settings.method');
|
||||
setValue(
|
||||
'settings.password',
|
||||
RandomUtil.randomShadowsocksPassword(method as string),
|
||||
);
|
||||
}}
|
||||
@@ -46,7 +47,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name={['settings', 'network']} label={t('pages.inbounds.network')}>
|
||||
<FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
@@ -55,14 +56,14 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
|
||||
{ value: 'udp', label: 'UDP' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'ivCheck']}
|
||||
label="ivCheck"
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,84 +1,73 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
interface StringListProps {
|
||||
name: string[];
|
||||
label: ReactNode;
|
||||
placeholder: (index: number) => string;
|
||||
}
|
||||
|
||||
function StringList({ name, label, placeholder }: StringListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: name.join('.') });
|
||||
return (
|
||||
<Form.Item label={label}>
|
||||
<Button aria-label={t('add')} size="small" onClick={() => append('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
<Space.Compact key={field.id} block className="mt-4">
|
||||
<FormField name={[...name, j]} noStyle>
|
||||
<Input placeholder={placeholder(j)} />
|
||||
</FormField>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(j)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TunFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
|
||||
<FormField name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
|
||||
<Input placeholder="xray0" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'mtu']} label="MTU">
|
||||
</FormField>
|
||||
<FormField name={['settings', 'mtu']} label="MTU">
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.List name={['settings', 'gateway']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label={t('pages.inbounds.info.gateway')}>
|
||||
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
<Space.Compact key={field.key} block className="mt-4">
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
|
||||
</Form.Item>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.List name={['settings', 'dns']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label="DNS">
|
||||
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
<Space.Compact key={field.key} block className="mt-4">
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
|
||||
</Form.Item>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
|
||||
</FormField>
|
||||
<StringList
|
||||
name={['settings', 'gateway']}
|
||||
label={t('pages.inbounds.info.gateway')}
|
||||
placeholder={(j) => (j === 0 ? '10.0.0.1/16' : 'fc00::1/64')}
|
||||
/>
|
||||
<StringList
|
||||
name={['settings', 'dns']}
|
||||
label="DNS"
|
||||
placeholder={(j) => (j === 0 ? '1.1.1.1' : '8.8.8.8')}
|
||||
/>
|
||||
<FormField name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.List name={['settings', 'autoSystemRoutingTable']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
|
||||
{t('pages.inbounds.info.autoSystemRoutes')}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
<Space.Compact key={field.key} block className="mt-4">
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
|
||||
</Form.Item>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<StringList
|
||||
name={['settings', 'autoSystemRoutingTable']}
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
|
||||
{t('pages.inbounds.info.autoSystemRoutes')}
|
||||
</Tooltip>
|
||||
}
|
||||
placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
|
||||
/>
|
||||
<FormField
|
||||
name={['settings', 'autoOutboundsInterface']}
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}>
|
||||
@@ -87,7 +76,7 @@ export default function TunFields() {
|
||||
}
|
||||
>
|
||||
<Input placeholder="auto" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { Input, InputNumber, Select, Switch } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function TunnelFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
|
||||
<FormField name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
|
||||
</FormField>
|
||||
<FormField name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
|
||||
<InputNumber min={0} max={65535} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
|
||||
</FormField>
|
||||
<FormField name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
||||
@@ -21,17 +22,17 @@ export default function TunnelFields() {
|
||||
{ value: 'udp', label: 'UDP' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'followRedirect']}
|
||||
label={t('pages.inbounds.form.followRedirect')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
|
||||
|
||||
interface VlessFieldsProps {
|
||||
@@ -24,6 +26,7 @@ export default function VlessFields({
|
||||
clearVlessEnc,
|
||||
}: VlessFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,12 +39,12 @@ export default function VlessFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
|
||||
<FormField name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
|
||||
</FormField>
|
||||
<FormField name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
|
||||
<Space size={8} wrap>
|
||||
<Select
|
||||
@@ -66,9 +69,22 @@ export default function VlessFields({
|
||||
>
|
||||
<Space.Compact block>
|
||||
{[900, 500, 900, 256].map((def, i) => (
|
||||
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
|
||||
<InputNumber min={1} style={{ width: '25%' }} />
|
||||
</Form.Item>
|
||||
<Controller
|
||||
key={i}
|
||||
control={control}
|
||||
name={`settings.testseed.${i}`}
|
||||
defaultValue={def}
|
||||
render={({ field }) => (
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '25%' }}
|
||||
value={field.value as number}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
interface WireguardFieldsProps {
|
||||
wgPubKey: string;
|
||||
regenInboundWg: () => void;
|
||||
@@ -13,29 +15,29 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={['settings', 'secretKey']} noStyle>
|
||||
<FormField name={['settings', 'secretKey']} noStyle>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
|
||||
<Input value={wgPubKey} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'mtu']} label="MTU">
|
||||
<FormField name={['settings', 'mtu']} label="MTU">
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
|
||||
</FormField>
|
||||
<FormField name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
|
||||
<Input placeholder="1.1.1.1, 1.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'noKernelTun']}
|
||||
label={t('pages.inbounds.info.noKernelTun')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
|
||||
</FormField>
|
||||
<FormField name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
@@ -46,7 +48,7 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
|
||||
{ value: 'ForceIPv6v4', label: 'ForceIPv6v4' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
@@ -41,43 +42,41 @@ export default function RealityForm({
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'show']}
|
||||
label={t('pages.inbounds.form.show')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
|
||||
label="uTLS"
|
||||
>
|
||||
<Select
|
||||
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.target')}
|
||||
tooltip={t('pages.inbounds.form.realityTargetHint')}
|
||||
>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'target']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
|
||||
if (errKey) throw new Error(t(errKey));
|
||||
},
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
|
||||
return errKey ? errKey : true;
|
||||
},
|
||||
]}
|
||||
}}
|
||||
>
|
||||
<Input style={{ flex: 1 }} placeholder="example.com:443" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
|
||||
{t('pages.inbounds.form.scan')}
|
||||
</Button>
|
||||
@@ -116,35 +115,35 @@ export default function RealityForm({
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
||||
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxTimediff']}
|
||||
label={t('pages.inbounds.form.maxTimeDiff')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'minClientVer']}
|
||||
label={t('pages.inbounds.form.minClientVer')}
|
||||
>
|
||||
<Input placeholder="25.9.11" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxClientVer']}
|
||||
label={t('pages.inbounds.form.maxClientVer')}
|
||||
>
|
||||
<Input placeholder="25.9.11" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.shortIds')}>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'shortIds']}
|
||||
noStyle
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -153,27 +152,27 @@ export default function RealityForm({
|
||||
tooltip={t('pages.inbounds.form.spiderXHint')}
|
||||
>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
|
||||
noStyle
|
||||
>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'privateKey']}
|
||||
label={t('pages.inbounds.privatekey')}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label=" ">
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={genRealityKeypair}>
|
||||
@@ -182,18 +181,18 @@ export default function RealityForm({
|
||||
<Button danger onClick={clearRealityKeypair}>{t('clear')}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
|
||||
label={t('pages.inbounds.form.mldsa65Seed')}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
|
||||
label={t('pages.inbounds.form.mldsa65Verify')}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label=" ">
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={genMldsa65}>
|
||||
@@ -202,13 +201,13 @@ export default function RealityForm({
|
||||
<Button danger onClick={clearMldsa65}>{t('clear')}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'masterKeyLog']}
|
||||
label={t('pages.inbounds.form.masterKeyLog')}
|
||||
tooltip={t('pages.inbounds.form.masterKeyLogTip')}
|
||||
>
|
||||
<Input placeholder="/path/to/sslkeylog.txt" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Collapse
|
||||
style={{ marginBottom: 14 }}
|
||||
items={[
|
||||
@@ -222,27 +221,27 @@ export default function RealityForm({
|
||||
<Divider style={{ margin: '0 0 14px 0' }}>
|
||||
{t(`pages.inbounds.form.${dir}`)}
|
||||
</Divider>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', dir, 'afterBytes']}
|
||||
label={t('pages.inbounds.form.afterBytes')}
|
||||
tooltip={t('pages.inbounds.form.afterBytesTip')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', dir, 'bytesPerSec']}
|
||||
label={t('pages.inbounds.form.bytesPerSec')}
|
||||
tooltip={t('pages.inbounds.form.bytesPerSecTip')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', dir, 'burstBytesPerSec']}
|
||||
label={t('pages.inbounds.form.burstBytesPerSec')}
|
||||
tooltip={t('pages.inbounds.form.burstBytesPerSecTip')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
|
||||
import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
ALPN_OPTION,
|
||||
DOMAIN_STRATEGY_OPTION,
|
||||
@@ -14,6 +16,11 @@ import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const CERT_LINES_TRANSFORM = {
|
||||
input: (v: unknown) => (Array.isArray(v) ? v.join('\n') : v),
|
||||
output: (raw: unknown) => (typeof raw === 'string' ? raw.split('\n') : raw),
|
||||
};
|
||||
|
||||
interface TlsFormProps {
|
||||
saving: boolean;
|
||||
setCertFromPanel: (certName: number) => void;
|
||||
@@ -24,6 +31,178 @@ interface TlsFormProps {
|
||||
clearEchCert: () => void;
|
||||
}
|
||||
|
||||
interface CertRowProps {
|
||||
index: number;
|
||||
total: number;
|
||||
saving: boolean;
|
||||
onRemove: () => void;
|
||||
setCertFromPanel: (certName: number) => void;
|
||||
clearCertFiles: (certName: number) => void;
|
||||
}
|
||||
|
||||
function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFiles }: CertRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const useFile = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.useFile` });
|
||||
const usage = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.usage` });
|
||||
return (
|
||||
<div>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'useFile']}
|
||||
label={`${t('certificate')} ${index + 1}`}
|
||||
>
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value={true}>
|
||||
{t('pages.inbounds.certificatePath')}
|
||||
</Radio.Button>
|
||||
<Radio.Button value={false}>
|
||||
{t('pages.inbounds.certificateContent')}
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</FormField>
|
||||
{total > 1 && (
|
||||
<Form.Item label=" ">
|
||||
<Button size="small" danger onClick={onRemove}>
|
||||
<MinusOutlined /> {t('remove')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
{useFile ? (
|
||||
<>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'certificateFile']}
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'keyFile']}
|
||||
label={t('pages.inbounds.privatekey')}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<Form.Item label=" ">
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saving}
|
||||
onClick={() => setCertFromPanel(index)}
|
||||
>
|
||||
{t('pages.inbounds.setDefaultCert')}
|
||||
</Button>
|
||||
<Button danger onClick={() => clearCertFiles(index)}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'certificate']}
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
transform={CERT_LINES_TRANSFORM}
|
||||
>
|
||||
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'key']}
|
||||
label={t('pages.inbounds.privatekey')}
|
||||
transform={CERT_LINES_TRANSFORM}
|
||||
>
|
||||
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'ocspStapling']}
|
||||
label="OCSP Stapling"
|
||||
>
|
||||
<InputNumber min={0} suffix="s" style={{ width: '50%' }} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'oneTimeLoading']}
|
||||
label={t('pages.inbounds.form.oneTimeLoading')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
|
||||
label={t('pages.inbounds.form.usageOption')}
|
||||
>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
|
||||
/>
|
||||
</FormField>
|
||||
{usage === 'issue' && (
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'buildChain']}
|
||||
label={t('pages.inbounds.form.buildChain')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EchSockoptSection() {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' });
|
||||
const on = !!echSockopt;
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
|
||||
<Switch
|
||||
checked={on}
|
||||
onChange={(v) =>
|
||||
setValue(
|
||||
'streamSettings.tlsSettings.echSockopt',
|
||||
v ? SockoptStreamSettingsSchema.parse({}) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
|
||||
label={t('pages.inbounds.form.dialerProxy')}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
>
|
||||
<Select
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
|
||||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
|
||||
label={t('pages.inbounds.form.multipathTcp')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TlsForm({
|
||||
saving,
|
||||
setCertFromPanel,
|
||||
@@ -34,36 +213,41 @@ export default function TlsForm({
|
||||
clearEchCert,
|
||||
}: TlsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'streamSettings.tlsSettings.certificates',
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
|
||||
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: t('pages.inbounds.form.autoOption') },
|
||||
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
|
||||
label="uTLS"
|
||||
>
|
||||
@@ -73,16 +257,16 @@ export default function TlsForm({
|
||||
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
|
||||
<Select
|
||||
mode="multiple"
|
||||
tokenSeparators={[',']}
|
||||
style={{ width: '100%' }}
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'curvePreferences']}
|
||||
label={t('pages.inbounds.form.curvePreferences')}
|
||||
tooltip={t('pages.inbounds.form.curvePreferencesTip')}
|
||||
@@ -96,274 +280,77 @@ export default function TlsForm({
|
||||
label: c,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
|
||||
label={t('pages.inbounds.form.rejectUnknownSni')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
|
||||
label={t('pages.inbounds.form.disableSystemRoot')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
|
||||
label={t('pages.inbounds.form.sessionResumption')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
|
||||
{(certFields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('certificate')}>
|
||||
<Button
|
||||
aria-label={t('add')}
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => add({
|
||||
useFile: true,
|
||||
certificateFile: '',
|
||||
keyFile: '',
|
||||
certificate: [],
|
||||
key: [],
|
||||
ocspStapling: 0,
|
||||
oneTimeLoading: false,
|
||||
usage: 'encipherment',
|
||||
buildChain: false,
|
||||
})}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{certFields.map((certField, idx) => (
|
||||
<div key={certField.key}>
|
||||
<Form.Item
|
||||
name={[certField.name, 'useFile']}
|
||||
label={`${t('certificate')} ${idx + 1}`}
|
||||
>
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value={true}>
|
||||
{t('pages.inbounds.certificatePath')}
|
||||
</Radio.Button>
|
||||
<Radio.Button value={false}>
|
||||
{t('pages.inbounds.certificateContent')}
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{certFields.length > 1 && (
|
||||
<Form.Item label=" ">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => remove(certField.name)}
|
||||
>
|
||||
<MinusOutlined /> {t('remove')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
|
||||
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const useFile = getFieldValue([
|
||||
'streamSettings', 'tlsSettings', 'certificates',
|
||||
certField.name, 'useFile',
|
||||
]);
|
||||
return useFile ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name={[certField.name, 'certificateFile']}
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[certField.name, 'keyFile']}
|
||||
label={t('pages.inbounds.privatekey')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label=" ">
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saving}
|
||||
onClick={() => setCertFromPanel(certField.name)}
|
||||
>
|
||||
{t('pages.inbounds.setDefaultCert')}
|
||||
</Button>
|
||||
<Button danger onClick={() => clearCertFiles(certField.name)}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name={[certField.name, 'certificate']}
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
normalize={(v) => typeof v === 'string'
|
||||
? v.split('\n')
|
||||
: v}
|
||||
getValueProps={(v) => ({
|
||||
value: Array.isArray(v) ? v.join('\n') : v,
|
||||
})}
|
||||
>
|
||||
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[certField.name, 'key']}
|
||||
label={t('pages.inbounds.privatekey')}
|
||||
normalize={(v) => typeof v === 'string'
|
||||
? v.split('\n')
|
||||
: v}
|
||||
getValueProps={(v) => ({
|
||||
value: Array.isArray(v) ? v.join('\n') : v,
|
||||
})}
|
||||
>
|
||||
<TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[certField.name, 'ocspStapling']}
|
||||
label="OCSP Stapling"
|
||||
>
|
||||
<InputNumber min={0} suffix="s" style={{ width: '50%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[certField.name, 'oneTimeLoading']}
|
||||
label={t('pages.inbounds.form.oneTimeLoading')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[certField.name, 'usage']}
|
||||
label={t('pages.inbounds.form.usageOption')}
|
||||
>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
|
||||
!== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const usage = getFieldValue([
|
||||
'streamSettings', 'tlsSettings', 'certificates',
|
||||
certField.name, 'usage',
|
||||
]);
|
||||
if (usage !== 'issue') return null;
|
||||
return (
|
||||
<Form.Item
|
||||
name={[certField.name, 'buildChain']}
|
||||
label={t('pages.inbounds.form.buildChain')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item
|
||||
<Form.Item label={t('certificate')}>
|
||||
<Button
|
||||
aria-label={t('add')}
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => append({
|
||||
useFile: true,
|
||||
certificateFile: '',
|
||||
keyFile: '',
|
||||
certificate: [],
|
||||
key: [],
|
||||
ocspStapling: 0,
|
||||
oneTimeLoading: false,
|
||||
usage: 'encipherment',
|
||||
buildChain: false,
|
||||
})}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.map((field, idx) => (
|
||||
<CertRow
|
||||
key={field.id}
|
||||
index={idx}
|
||||
total={fields.length}
|
||||
saving={saving}
|
||||
onRemove={() => remove(idx)}
|
||||
setCertFromPanel={setCertFromPanel}
|
||||
clearCertFiles={clearCertFiles}
|
||||
/>
|
||||
))}
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'masterKeyLog']}
|
||||
label={t('pages.inbounds.form.masterKeyLog')}
|
||||
tooltip={t('pages.inbounds.form.masterKeyLogTip')}
|
||||
>
|
||||
<Input placeholder="/path/to/sslkeylog.txt" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
!!(prev.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
|
||||
!== !!(curr.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
|
||||
}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const on = !!getFieldValue(['streamSettings', 'tlsSettings', 'echSockopt']);
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
|
||||
<Switch
|
||||
checked={on}
|
||||
onChange={(v) =>
|
||||
setFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'echSockopt'],
|
||||
v ? SockoptStreamSettingsSchema.parse({}) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
|
||||
label={t('pages.inbounds.form.dialerProxy')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
>
|
||||
<Select
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
|
||||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
|
||||
label={t('pages.inbounds.form.multipathTcp')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
|
||||
</FormField>
|
||||
<EchSockoptSection />
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
|
||||
label={t('pages.inbounds.form.echConfig')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label=" ">
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={getNewEchCert}>
|
||||
@@ -377,7 +364,7 @@ export default function TlsForm({
|
||||
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
|
||||
>
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
|
||||
noStyle
|
||||
>
|
||||
@@ -387,7 +374,7 @@ export default function TlsForm({
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
style={{ width: 'calc(100% - 64px)' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Button
|
||||
icon={<FileProtectOutlined />}
|
||||
onClick={pinFromCert}
|
||||
@@ -402,13 +389,13 @@ export default function TlsForm({
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
|
||||
label={t('pages.inbounds.form.verifyPeerCertByName')}
|
||||
tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
|
||||
>
|
||||
<Input placeholder="example.com" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Switch } from 'antd';
|
||||
import { Input, Switch } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function GrpcForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'grpcSettings', 'serviceName']}
|
||||
label={t('pages.inbounds.form.serviceName')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'grpcSettings', 'authority']}
|
||||
label={t('pages.inbounds.form.authority')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'grpcSettings', 'multiMode']}
|
||||
label={t('pages.inbounds.form.multiMode')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Switch } from 'antd';
|
||||
import { Input, Switch } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function HttpUpgradeForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'httpupgradeSettings', 'acceptProxyProtocol']}
|
||||
label={t('pages.inbounds.form.proxyProtocol')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'httpupgradeSettings', 'host']}
|
||||
label={t('host')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'httpupgradeSettings', 'path']}
|
||||
label={t('path')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={['streamSettings', 'httpupgradeSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, InputNumber } from 'antd';
|
||||
import { InputNumber } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function KcpForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
|
||||
<FormField name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
|
||||
<InputNumber min={576} max={1460} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
|
||||
<InputNumber min={10} max={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
|
||||
label={t('pages.inbounds.form.cwndMultiplier')}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
|
||||
label={t('pages.inbounds.form.maxSendingWindow')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,157 +1,115 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function RawForm() {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const headerType = (useWatch({
|
||||
control,
|
||||
name: 'streamSettings.tcpSettings.header.type',
|
||||
}) ?? 'none') as string;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'tcpSettings', 'acceptProxyProtocol']}
|
||||
label={t('pages.inbounds.form.proxyProtocol')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label={`HTTP ${t('camouflage')}`}>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.tcpSettings?.header?.type
|
||||
!== curr.streamSettings?.tcpSettings?.header?.type
|
||||
}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const headerType = getFieldValue(
|
||||
['streamSettings', 'tcpSettings', 'header', 'type'],
|
||||
) as string | undefined;
|
||||
return (
|
||||
<Switch
|
||||
checked={headerType === 'http'}
|
||||
onChange={(v) => {
|
||||
setFieldValue(
|
||||
['streamSettings', 'tcpSettings', 'header'],
|
||||
v
|
||||
? {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ['/'],
|
||||
headers: {},
|
||||
},
|
||||
response: {
|
||||
version: '1.1',
|
||||
status: '200',
|
||||
reason: 'OK',
|
||||
headers: {},
|
||||
},
|
||||
}
|
||||
: { type: 'none' },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
checked={headerType === 'http'}
|
||||
onChange={(v) => {
|
||||
setValue(
|
||||
'streamSettings.tcpSettings.header',
|
||||
v
|
||||
? {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ['/'],
|
||||
headers: {},
|
||||
},
|
||||
response: {
|
||||
version: '1.1',
|
||||
status: '200',
|
||||
reason: 'OK',
|
||||
headers: {},
|
||||
},
|
||||
}
|
||||
: { type: 'none' },
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.tcpSettings?.header?.type
|
||||
!== curr.streamSettings?.tcpSettings?.header?.type
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const headerType = getFieldValue(
|
||||
['streamSettings', 'tcpSettings', 'header', 'type'],
|
||||
) as string | undefined;
|
||||
if (headerType !== 'http') return null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestVersion')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'version',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestMethod')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'method',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="GET" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestPath')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'path',
|
||||
]}
|
||||
getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
|
||||
getValueFromEvent={(e) => {
|
||||
const raw = (e?.target?.value ?? '') as string;
|
||||
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : ['/'];
|
||||
}}
|
||||
>
|
||||
<Input placeholder="/" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestHeaders')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'headers',
|
||||
]}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseVersion')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'version',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseStatus')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'status',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="200" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseReason')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'reason',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="OK" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseHeaders')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'headers',
|
||||
]}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{headerType === 'http' && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestVersion')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'version']}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestMethod')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'method']}
|
||||
>
|
||||
<Input placeholder="GET" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestPath')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'path']}
|
||||
transform={{
|
||||
input: (v) => (Array.isArray(v) ? v.join(',') : v),
|
||||
output: (raw) => {
|
||||
const parts = String(raw ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts : ['/'];
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="/" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestHeaders')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseVersion')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'version']}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseStatus')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'status']}
|
||||
>
|
||||
<Input placeholder="200" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseReason')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'reason']}
|
||||
>
|
||||
<Input placeholder="OK" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseHeaders')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CustomSockoptList } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SockoptCustomField } from '@/lib/xray/forms/fields';
|
||||
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
|
||||
// Transport key that carries its own acceptProxyProtocol field (mirrored
|
||||
// alongside the sockopt-level one so the PROXY preset never silently no-ops).
|
||||
/* Transport key that carries its own acceptProxyProtocol field (mirrored
|
||||
alongside the sockopt-level one so the PROXY preset never silently no-ops). */
|
||||
const TRANSPORT_PROXY_FIELD: Record<string, string> = {
|
||||
tcp: 'tcpSettings',
|
||||
ws: 'wsSettings',
|
||||
httpupgrade: 'httpupgradeSettings',
|
||||
};
|
||||
// Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
|
||||
// in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
|
||||
// instead of the old x-real-ip gRPC metadata.
|
||||
/* Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
|
||||
in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
|
||||
instead of the old x-real-ip gRPC metadata. */
|
||||
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp', 'grpc'];
|
||||
|
||||
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
|
||||
@@ -26,252 +28,207 @@ export default function SockoptForm({
|
||||
network: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { control, getValues, setValue } = useFormContext();
|
||||
const sock = useWatch({ control, name: 'streamSettings.sockopt' });
|
||||
const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
|
||||
|
||||
// Presets write the same sockopt fields the user could set by hand below,
|
||||
// picking the mechanism xray-core actually honors for the chosen transport:
|
||||
// CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
|
||||
// PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
|
||||
const applyRealClientIpPreset = (
|
||||
preset: RealClientIpPreset,
|
||||
getFieldValue: (name: (string | number)[]) => unknown,
|
||||
setFieldValue: (name: (string | number)[], value: unknown) => void,
|
||||
) => {
|
||||
const sockopt = getFieldValue(['streamSettings', 'sockopt']);
|
||||
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||
const sockAcceptPP = useWatch({ control, name: 'streamSettings.sockopt.acceptProxyProtocol' });
|
||||
const sockTrusted = useWatch({ control, name: 'streamSettings.sockopt.trustedXForwardedFor' });
|
||||
const transportAcceptPP = useWatch({
|
||||
control,
|
||||
name: transportField ? `streamSettings.${transportField}.acceptProxyProtocol` : 'streamSettings.__noTransportProxyField',
|
||||
});
|
||||
|
||||
/* Presets write the same sockopt fields the user could set by hand below,
|
||||
picking the mechanism xray-core actually honors for the chosen transport:
|
||||
CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
|
||||
PROXY-protocol header via acceptProxyProtocol (every transport but mKCP). */
|
||||
const applyRealClientIpPreset = (preset: RealClientIpPreset) => {
|
||||
const sockopt = getValues('streamSettings.sockopt');
|
||||
const sockoptOn =
|
||||
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
|
||||
if (preset !== 'off' && !sockoptOn) {
|
||||
toggleSockopt(true);
|
||||
}
|
||||
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||
|
||||
if (preset === 'off') {
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
|
||||
setValue('streamSettings.sockopt.trustedXForwardedFor', []);
|
||||
setValue('streamSettings.sockopt.acceptProxyProtocol', false);
|
||||
if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset === 'cloudflare') {
|
||||
const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
|
||||
const current = getValues('streamSettings.sockopt.trustedXForwardedFor');
|
||||
const list = Array.isArray(current) ? [...(current as string[])] : [];
|
||||
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
|
||||
setValue('streamSettings.sockopt.trustedXForwardedFor', list);
|
||||
setValue('streamSettings.sockopt.acceptProxyProtocol', false);
|
||||
if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// proxy — clear trustedXForwardedFor so a lingering header can't override the
|
||||
// PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc).
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
|
||||
/* proxy — clear trustedXForwardedFor so a lingering header can't override the
|
||||
PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc). */
|
||||
setValue('streamSettings.sockopt.trustedXForwardedFor', []);
|
||||
setValue('streamSettings.sockopt.acceptProxyProtocol', true);
|
||||
if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, true);
|
||||
};
|
||||
|
||||
const transportPP = transportField ? transportAcceptPP === true : false;
|
||||
const proxyOn = sockAcceptPP === true || transportPP;
|
||||
const trusted = Array.isArray(sockTrusted) ? (sockTrusted as string[]) : [];
|
||||
const presetValue: RealClientIpPreset = proxyOn
|
||||
? 'proxy'
|
||||
: trusted.length > 0
|
||||
? 'cloudflare'
|
||||
: 'off';
|
||||
const trustedMismatch = trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
|
||||
const proxyMismatch = proxyOn && network === 'kcp';
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => {
|
||||
const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt;
|
||||
const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt;
|
||||
return !!a !== !!b;
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const sock = getFieldValue(['streamSettings', 'sockopt']);
|
||||
const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Sockopt">
|
||||
<Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => {
|
||||
type ProxyWatch = {
|
||||
streamSettings?: {
|
||||
sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
|
||||
tcpSettings?: { acceptProxyProtocol?: unknown };
|
||||
wsSettings?: { acceptProxyProtocol?: unknown };
|
||||
httpupgradeSettings?: { acceptProxyProtocol?: unknown };
|
||||
};
|
||||
};
|
||||
const pick = (v: ProxyWatch) => {
|
||||
const s = v.streamSettings;
|
||||
return JSON.stringify([
|
||||
s?.sockopt?.trustedXForwardedFor,
|
||||
s?.sockopt?.acceptProxyProtocol,
|
||||
s?.tcpSettings?.acceptProxyProtocol,
|
||||
s?.wsSettings?.acceptProxyProtocol,
|
||||
s?.httpupgradeSettings?.acceptProxyProtocol,
|
||||
]);
|
||||
};
|
||||
return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||
const transportPP = transportField
|
||||
? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
|
||||
: false;
|
||||
const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
|
||||
const trusted = Array.isArray(sockopt.trustedXForwardedFor)
|
||||
? (sockopt.trustedXForwardedFor as string[])
|
||||
: [];
|
||||
const value: RealClientIpPreset = proxyOn
|
||||
? 'proxy'
|
||||
: trusted.length > 0
|
||||
? 'cloudflare'
|
||||
: 'off';
|
||||
const trustedMismatch =
|
||||
trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
|
||||
const proxyMismatch = proxyOn && network === 'kcp';
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.realClientIp')}
|
||||
tooltip={t('pages.inbounds.form.realClientIpHint')}
|
||||
>
|
||||
<Segmented
|
||||
value={value}
|
||||
onChange={(v) =>
|
||||
applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
|
||||
}
|
||||
options={[
|
||||
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
|
||||
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
|
||||
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{trustedMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
{proxyMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
|
||||
label={t('pages.inbounds.form.tcpKeepAliveInterval')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
|
||||
label={t('pages.inbounds.form.tcpKeepAliveIdle')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
|
||||
label={t('pages.inbounds.form.tcpUserTimeout')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
||||
label={t('pages.inbounds.form.proxyProtocol')}
|
||||
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
|
||||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'penetrate']}
|
||||
label={t('pages.inbounds.form.penetrate')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'V6Only']}
|
||||
label={t('pages.inbounds.form.v6Only')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||
label={t('pages.inbounds.form.tcpCongestion')}
|
||||
>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'redirect', label: 'Redirect' },
|
||||
{ value: 'tproxy', label: 'TProxy' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||||
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
{ value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
|
||||
{ value: 'X-Real-IP', label: 'X-Real-IP' },
|
||||
{ value: 'True-Client-IP', label: 'True-Client-IP' },
|
||||
{ value: 'X-Client-IP', label: 'X-Client-IP' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<CustomSockoptList />
|
||||
</>
|
||||
<>
|
||||
<Form.Item label="Sockopt">
|
||||
<Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.realClientIp')}
|
||||
tooltip={t('pages.inbounds.form.realClientIpHint')}
|
||||
>
|
||||
<Segmented
|
||||
value={presetValue}
|
||||
onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
|
||||
options={[
|
||||
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
|
||||
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
|
||||
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{trustedMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
{proxyMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
<FormField name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
|
||||
label={t('pages.inbounds.form.tcpKeepAliveInterval')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
|
||||
label={t('pages.inbounds.form.tcpKeepAliveIdle')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
|
||||
label={t('pages.inbounds.form.tcpUserTimeout')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
||||
label={t('pages.inbounds.form.proxyProtocol')}
|
||||
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
|
||||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'penetrate']}
|
||||
label={t('pages.inbounds.form.penetrate')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'V6Only']}
|
||||
label={t('pages.inbounds.form.v6Only')}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||
label={t('pages.inbounds.form.tcpCongestion')}
|
||||
>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'redirect', label: 'Redirect' },
|
||||
{ value: 'tproxy', label: 'TProxy' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||||
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
options={[
|
||||
{ value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
|
||||
{ value: 'X-Real-IP', label: 'X-Real-IP' },
|
||||
{ value: 'True-Client-IP', label: 'True-Client-IP' },
|
||||
{ value: 'X-Client-IP', label: 'X-Client-IP' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
control={control}
|
||||
name="streamSettings.sockopt.customSockopt"
|
||||
render={({ field }) => (
|
||||
<SockoptCustomField value={field.value} onChange={field.onChange} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Switch } from 'antd';
|
||||
import { Input, InputNumber, Switch } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function WsForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'wsSettings', 'acceptProxyProtocol']}
|
||||
label={t('pages.inbounds.form.proxyProtocol')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
|
||||
label={t('pages.inbounds.form.heartbeatPeriod')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={['streamSettings', 'wsSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
|
||||
|
||||
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||
|
||||
export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
|
||||
function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
|
||||
return async (value: unknown): Promise<true | string> => {
|
||||
try {
|
||||
await fn(undefined, value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return (e as Error).message;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function XhttpForm() {
|
||||
const { t } = useTranslation();
|
||||
const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
|
||||
const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
|
||||
const xhttpSessionIDPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDPlacement'], form);
|
||||
const xhttpSessionIDTable = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDTable'], form);
|
||||
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
|
||||
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
|
||||
const { control, getValues, setValue } = useFormContext();
|
||||
const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as string | undefined;
|
||||
const xhttpObfsMode = !!useWatch({ control, name: 'streamSettings.xhttpSettings.xPaddingObfsMode' });
|
||||
const xhttpSessionIDPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDPlacement' }) as string | undefined;
|
||||
const xhttpSessionIDTable = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDTable' });
|
||||
const xhttpSeqPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.seqPlacement' }) as string | undefined;
|
||||
const xhttpUplinkPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.uplinkDataPlacement' }) as string | undefined;
|
||||
const enableXmux = !!useWatch({ control, name: 'streamSettings.xhttpSettings.enableXmux' });
|
||||
|
||||
function onXmuxToggle(checked: boolean) {
|
||||
if (!checked) return;
|
||||
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
|
||||
const existing = getValues('streamSettings.xhttpSettings.xmux');
|
||||
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
||||
if (hasValues) return;
|
||||
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
|
||||
setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
|
||||
<FormField name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
|
||||
@@ -41,64 +55,64 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
||||
label: m,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{(xhttpMode === 'packet-up' || xhttpMode === 'auto') && (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
|
||||
label={t('pages.inbounds.form.maxUploadSize')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
|
||||
label={t('pages.inbounds.form.maxBufferedUpload')}
|
||||
>
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
|
||||
label={t('pages.xray.outboundForm.minUploadInterval')}
|
||||
>
|
||||
<Input placeholder="e.g. 50-150" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{xhttpMode === 'stream-up' && (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
|
||||
label={t('pages.inbounds.form.maxBufferedUpload')}
|
||||
>
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
|
||||
label={t('pages.inbounds.form.streamUpServer')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}
|
||||
label={t('pages.inbounds.form.serverMaxHeaderBytes')}
|
||||
>
|
||||
<InputNumber min={0} placeholder="0 (default)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
|
||||
label={t('pages.inbounds.form.paddingBytes')}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'headers']}
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
|
||||
label={t('pages.inbounds.form.uplinkHttpMethod')}
|
||||
>
|
||||
@@ -114,29 +128,29 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
|
||||
label={t('pages.inbounds.form.paddingObfsMode')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{xhttpObfsMode && (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
|
||||
label={t('pages.inbounds.form.paddingKey')}
|
||||
>
|
||||
<Input placeholder="x_padding" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
|
||||
label={t('pages.inbounds.form.paddingHeader')}
|
||||
>
|
||||
<Input placeholder="X-Padding" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
|
||||
label={t('pages.inbounds.form.paddingPlacement')}
|
||||
>
|
||||
@@ -149,8 +163,8 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
|
||||
label={t('pages.inbounds.form.paddingMethod')}
|
||||
>
|
||||
@@ -161,10 +175,10 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
||||
{ value: 'tokenish', label: 'tokenish' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
|
||||
label={t('pages.inbounds.form.sessionPlacement')}
|
||||
>
|
||||
@@ -177,38 +191,38 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{xhttpSessionIDPlacement && xhttpSessionIDPlacement !== 'path' && (
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
|
||||
label={t('pages.inbounds.form.sessionKey')}
|
||||
>
|
||||
<Input placeholder="x_session" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
|
||||
label={t('pages.inbounds.form.sessionIDTable')}
|
||||
tooltip={t('pages.inbounds.form.sessionIDTableHint')}
|
||||
rules={[{ validator: validateSessionIDTable }]}
|
||||
rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
|
||||
>
|
||||
<AutoComplete
|
||||
allowClear
|
||||
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
|
||||
placeholder="Base62"
|
||||
/>
|
||||
</Form.Item>
|
||||
{xhttpSessionIDTable && (
|
||||
<Form.Item
|
||||
</FormField>
|
||||
{!!xhttpSessionIDTable && (
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
|
||||
label={t('pages.inbounds.form.sessionIDLength')}
|
||||
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
|
||||
rules={[{ validator: validateSessionIDLength }]}
|
||||
rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
|
||||
>
|
||||
<Input placeholder="8-16" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
|
||||
label={t('pages.inbounds.form.sequencePlacement')}
|
||||
>
|
||||
@@ -221,18 +235,18 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && (
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'seqKey']}
|
||||
label={t('pages.inbounds.form.sequenceKey')}
|
||||
>
|
||||
<Input placeholder="x_seq" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
{xhttpMode === 'packet-up' && (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
|
||||
label={t('pages.inbounds.form.uplinkDataPlacement')}
|
||||
>
|
||||
@@ -245,83 +259,77 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && (
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
|
||||
label={t('pages.inbounds.form.uplinkDataKey')}
|
||||
>
|
||||
<Input placeholder="x_data" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
<FormField
|
||||
name={['streamSettings', 'xhttpSettings', 'noSSEHeader']}
|
||||
label={t('pages.inbounds.form.noSseHeader')}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{/* XMUX is the connection-multiplexing layer
|
||||
xHTTP uses to fan out parallel requests over
|
||||
a small pool of upstream connections. UI-only
|
||||
toggle (enableXmux) hides the 6 nested knobs
|
||||
when off. */}
|
||||
<Form.Item
|
||||
<FormField
|
||||
label="XMUX"
|
||||
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
onAfterChange={(v) => onXmuxToggle(v as boolean)}
|
||||
>
|
||||
<Switch onChange={onXmuxToggle} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
if (!form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'enableXmux',
|
||||
])) return null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxConcurrency')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||
>
|
||||
<Input placeholder="16-32" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxConnections')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||
>
|
||||
<Input placeholder="0" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxReuseTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxRequestTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
|
||||
>
|
||||
<Input placeholder="600-900" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxReusableSecs')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
|
||||
>
|
||||
<Input placeholder="1800-3000" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.keepAlivePeriod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Switch />
|
||||
</FormField>
|
||||
{enableXmux && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConcurrency')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||
>
|
||||
<Input placeholder="16-32" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConnections')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||
>
|
||||
<Input placeholder="0" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxReuseTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxRequestTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
|
||||
>
|
||||
<Input placeholder="600-900" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxReusableSecs')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
|
||||
>
|
||||
<Input placeholder="1800-3000" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.keepAlivePeriod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { MessageInstance } from 'antd/es/message/interface';
|
||||
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
@@ -10,23 +10,29 @@ import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
|
||||
interface UseSecurityActionsArgs {
|
||||
form: FormInstance<InboundFormValues>;
|
||||
methods: UseFormReturn<InboundFormValues>;
|
||||
setSaving: Dispatch<SetStateAction<boolean>>;
|
||||
messageApi: MessageInstance;
|
||||
// Node the inbound is deployed to (null = central panel). "Set Cert from
|
||||
// Panel" must read the node's own cert paths for a node-assigned inbound —
|
||||
// the central panel's paths don't exist on the node. See issue #4854.
|
||||
/*
|
||||
* Node the inbound is deployed to (null = central panel). "Set Cert from
|
||||
* Panel" must read the node's own cert paths for a node-assigned inbound —
|
||||
* the central panel's paths don't exist on the node. See issue #4854.
|
||||
*/
|
||||
nodeId: number | null;
|
||||
setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>;
|
||||
setScanning: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
// Server-side TLS / Reality key + certificate generation handlers for the
|
||||
// inbound modal's security tab. Each talks to a /panel server endpoint and
|
||||
// writes the result back into the form. Lifted out of InboundFormModal so
|
||||
// the modal body stays focused on orchestration.
|
||||
export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
|
||||
/*
|
||||
* Server-side TLS / Reality key + certificate generation handlers for the
|
||||
* inbound modal's security tab. Each talks to a /panel server endpoint and
|
||||
* writes the result back into the form. Lifted out of InboundFormModal so
|
||||
* the modal body stays focused on orchestration.
|
||||
*/
|
||||
export function useSecurityActions({ methods, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
|
||||
const { t } = useTranslation();
|
||||
const setValue = methods.setValue as unknown as (name: string, value: unknown) => void;
|
||||
const getValues = methods.getValues as unknown as (name?: string) => unknown;
|
||||
|
||||
const genRealityKeypair = async () => {
|
||||
setSaving(true);
|
||||
@@ -34,8 +40,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
||||
if (msg?.success) {
|
||||
const obj = msg.obj as { privateKey: string; publicKey: string };
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
|
||||
setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
|
||||
setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -43,8 +49,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
};
|
||||
|
||||
const clearRealityKeypair = () => {
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], '');
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], '');
|
||||
setValue('streamSettings.realitySettings.privateKey', '');
|
||||
setValue('streamSettings.realitySettings.settings.publicKey', '');
|
||||
};
|
||||
|
||||
const genMldsa65 = async () => {
|
||||
@@ -53,8 +59,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
|
||||
if (msg?.success) {
|
||||
const obj = msg.obj as { seed: string; verify: string };
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed);
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify);
|
||||
setValue('streamSettings.realitySettings.mldsa65Seed', obj.seed);
|
||||
setValue('streamSettings.realitySettings.settings.mldsa65Verify', obj.verify);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -62,20 +68,20 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
};
|
||||
|
||||
const clearMldsa65 = () => {
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], '');
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
|
||||
setValue('streamSettings.realitySettings.mldsa65Seed', '');
|
||||
setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
|
||||
};
|
||||
|
||||
const applyRealityScanResult = (r: RealityScanResult) => {
|
||||
setScanResult(r);
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target);
|
||||
setValue('streamSettings.realitySettings.target', r.target);
|
||||
if (r.serverNames?.length) {
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames);
|
||||
setValue('streamSettings.realitySettings.serverNames', r.serverNames);
|
||||
}
|
||||
};
|
||||
|
||||
const scanRealityTarget = async () => {
|
||||
const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim();
|
||||
const target = ((getValues('streamSettings.realitySettings.target') as string | undefined) ?? '').trim();
|
||||
if (!target) {
|
||||
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
|
||||
return;
|
||||
@@ -118,28 +124,28 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
};
|
||||
|
||||
const randomizeShortIds = () => {
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'realitySettings', 'shortIds'],
|
||||
setValue(
|
||||
'streamSettings.realitySettings.shortIds',
|
||||
RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
const randomizeSpiderX = () => {
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'realitySettings', 'settings', 'spiderX'],
|
||||
setValue(
|
||||
'streamSettings.realitySettings.settings.spiderX',
|
||||
`/${RandomUtil.randomSeq(15)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const getNewEchCert = async () => {
|
||||
const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
|
||||
const sni = getValues('streamSettings.tlsSettings.serverName');
|
||||
setSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
|
||||
if (msg?.success) {
|
||||
const obj = msg.obj as { echServerKeys: string; echConfigList: string };
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys);
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList);
|
||||
setValue('streamSettings.tlsSettings.echServerKeys', obj.echServerKeys);
|
||||
setValue('streamSettings.tlsSettings.settings.echConfigList', obj.echConfigList);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -147,15 +153,17 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
};
|
||||
|
||||
const clearEchCert = () => {
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], '');
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
|
||||
setValue('streamSettings.tlsSettings.echServerKeys', '');
|
||||
setValue('streamSettings.tlsSettings.settings.echConfigList', '');
|
||||
};
|
||||
|
||||
// Fill the pinned-cert field from the inbound's own certificate: read the
|
||||
// first configured cert (file path or inline content) and ask the server for
|
||||
// its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
|
||||
/*
|
||||
* Fill the pinned-cert field from the inbound's own certificate: read the
|
||||
* first configured cert (file path or inline content) and ask the server for
|
||||
* its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
|
||||
*/
|
||||
const pinFromCert = async () => {
|
||||
const certs = (form.getFieldValue(['streamSettings', 'tlsSettings', 'certificates']) ?? []) as Array<{
|
||||
const certs = (getValues('streamSettings.tlsSettings.certificates') ?? []) as Array<{
|
||||
certificateFile?: string;
|
||||
certificate?: string[];
|
||||
}>;
|
||||
@@ -175,29 +183,33 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
}
|
||||
const hashes = (msg.obj as string[] | undefined) ?? [];
|
||||
if (hashes.length === 0) return;
|
||||
const current = (form.getFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
||||
const current = (getValues(
|
||||
'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
|
||||
) as string[] | undefined) ?? [];
|
||||
const merged = Array.from(new Set([...current, ...hashes]));
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
|
||||
setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fill the pinned-cert field by pinging the configured SNI: fetches the live
|
||||
// remote certificate hash via `xray tls ping`. Useful when the panel doesn't
|
||||
// hold the cert file (a CDN front / external endpoint).
|
||||
/*
|
||||
* Fill the pinned-cert field by pinging the configured SNI: fetches the live
|
||||
* remote certificate hash via `xray tls ping`. Useful when the panel doesn't
|
||||
* hold the cert file (a CDN front / external endpoint).
|
||||
*/
|
||||
const pinFromRemote = async () => {
|
||||
const server = ((form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']) as string | undefined) ?? '').trim();
|
||||
const server = ((getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? '').trim();
|
||||
if (!server) {
|
||||
messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
|
||||
return;
|
||||
}
|
||||
// `xray tls ping` defaults to :443, but a self-hosted inbound rarely
|
||||
// listens there. Append the inbound's own port (unless the SNI already
|
||||
// carries one) so the ping reaches the actual TLS endpoint.
|
||||
const port = form.getFieldValue('port') as number | undefined;
|
||||
/*
|
||||
* `xray tls ping` defaults to :443, but a self-hosted inbound rarely
|
||||
* listens there. Append the inbound's own port (unless the SNI already
|
||||
* carries one) so the ping reaches the actual TLS endpoint.
|
||||
*/
|
||||
const port = getValues('port') as number | undefined;
|
||||
const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -208,11 +220,11 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
}
|
||||
const hashes = (msg.obj as string[] | undefined) ?? [];
|
||||
if (hashes.length === 0) return;
|
||||
const current = (form.getFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
|
||||
const current = (getValues(
|
||||
'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
|
||||
) as string[] | undefined) ?? [];
|
||||
const merged = Array.from(new Set([...current, ...hashes]));
|
||||
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
|
||||
setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -221,8 +233,10 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
const setCertFromPanel = async (certName: number) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Node-assigned inbounds run on the node, so their cert files must be the
|
||||
// node's own paths (fetched through the central panel), not this panel's.
|
||||
/*
|
||||
* Node-assigned inbounds run on the node, so their cert files must be the
|
||||
* node's own paths (fetched through the central panel), not this panel's.
|
||||
*/
|
||||
const msg = typeof nodeId === 'number'
|
||||
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
||||
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||
@@ -235,12 +249,12 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
|
||||
return;
|
||||
}
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
|
||||
setValue(
|
||||
`streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
|
||||
obj.webCertFile ?? '',
|
||||
);
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
|
||||
setValue(
|
||||
`streamSettings.tlsSettings.certificates.${certName}.keyFile`,
|
||||
obj.webKeyFile ?? '',
|
||||
);
|
||||
} finally {
|
||||
@@ -249,19 +263,19 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
};
|
||||
|
||||
const clearCertFiles = (certName: number) => {
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
|
||||
setValue(
|
||||
`streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
|
||||
'',
|
||||
);
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
|
||||
setValue(
|
||||
`streamSettings.tlsSettings.certificates.${certName}.keyFile`,
|
||||
'',
|
||||
);
|
||||
};
|
||||
|
||||
const onSecurityChange = async (next: string) => {
|
||||
setScanResult(null);
|
||||
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
|
||||
const current = (getValues('streamSettings') as Record<string, unknown>) ?? {};
|
||||
const cleaned: Record<string, unknown> = { ...current, security: next };
|
||||
delete cleaned.tlsSettings;
|
||||
delete cleaned.realitySettings;
|
||||
@@ -275,18 +289,18 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
|
||||
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
|
||||
cleaned.realitySettings = reality;
|
||||
}
|
||||
form.setFieldValue('streamSettings', cleaned);
|
||||
setValue('streamSettings', cleaned);
|
||||
if (next === 'reality') {
|
||||
randomizeSpiderX();
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
|
||||
if (msg?.success) {
|
||||
const obj = msg.obj as { privateKey: string; publicKey: string };
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
|
||||
setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
|
||||
setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
|
||||
}
|
||||
} catch {
|
||||
// best-effort: leave keypair fields empty if server call fails
|
||||
/* best-effort: leave keypair fields empty if server call fails */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user