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:
Sanaei
2026-07-08 13:28:37 +02:00
committed by GitHub
parent 8ee79cf447
commit 61e12e4c29
98 changed files with 9496 additions and 5089 deletions
@@ -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>
))}
</>
+249 -262
View File
@@ -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 */
}
}
};