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
+3
View File
@@ -11,6 +11,9 @@ on:
- 'docs/**'
- '.github/workflows/docs-ci.yml'
permissions:
contents: read
defaults:
run:
working-directory: docs
+1
View File
@@ -3,3 +3,4 @@ node_modules/
*.log
*.tsbuildinfo
coverage/
storybook-static/
+1
View File
@@ -0,0 +1 @@
cd frontend && npx lint-staged
+25
View File
@@ -0,0 +1,25 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
framework: {
name: '@storybook/react-vite',
options: {},
},
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
viteFinal: (viteConfig) => {
if (viteConfig.build) {
viteConfig.build.outDir = undefined;
viteConfig.build.emptyOutDir = false;
if (viteConfig.build.rollupOptions) {
viteConfig.build.rollupOptions.input = undefined;
}
}
if (viteConfig.experimental) {
viteConfig.experimental.renderBuiltUrl = undefined;
}
return viteConfig;
},
};
export default config;
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react-vite';
import { ConfigProvider, theme as antdTheme } from 'antd';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import enUS from '../../internal/web/translation/en-US.json';
if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: 'en-US',
fallbackLng: 'en-US',
resources: { 'en-US': { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
returnNull: false,
});
}
const withTheme: Decorator = (Story, context) => {
const dark = context.globals.theme === 'dark';
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}, [dark]);
return (
<ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}>
<div style={{ padding: 24, minWidth: 320 }}>
<Story />
</div>
</ConfigProvider>
);
};
const preview: Preview = {
decorators: [withTheme],
globalTypes: {
theme: {
description: 'Ant Design theme',
defaultValue: 'light',
toolbar: {
title: 'Theme',
icon: 'circlehollow',
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
],
dynamicTitle: true,
},
},
},
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
+11 -4
View File
@@ -27,12 +27,19 @@ The `@` import alias maps to `src/`.
`src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`.
## Rules
- Ant Design 6 only; no Tailwind/shadcn (a migration was rolled back).
- Ant Design 6 for components; no Tailwind/shadcn (a migration was rolled back).
Form *state* runs on React Hook Form (`src/components/form/rhf/`), not Ant
Design's `Form` store.
- Function components + hooks only; no class components.
- No `//` line comments in committed TS/TSX. HTML comments are fine.
- TS strict; `no-explicit-any` is an error. Validate form fields with
`antdRule(Schema.shape.field, t)` from `@/utils/zodForm`, not inline
`z.string()`.
- TS strict; `no-explicit-any` is an error. Build forms with `useZodForm` +
`FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
validate through the `zodResolver` or per-field
`rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
issue keys resolved via `t()`, never inline `z.string()`. AntD `<Form>` stays
only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
`src/lib/xray/forms/fields/`, bound via a `Controller`.
- New `g.POST`/`g.GET` route => add it to `src/pages/api-docs/endpoints.ts`,
then `npm run gen`.
- i18n strings live in `internal/web/translation/<locale>.json`, NOT under
+3469
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -16,14 +16,21 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen"
"gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true"
},
"lint-staged": {
"src/**/*.{ts,tsx}": "eslint --fix"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.4.0",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
@@ -35,6 +42,7 @@
"persian-calendar-suite": "^1.5.5",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.18.1",
"swagger-ui-react": "^5.32.8",
@@ -43,6 +51,9 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6",
"@storybook/react-vite": "^10.4.6",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
@@ -54,7 +65,11 @@
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"msw": "^2.14.7",
"storybook": "^10.4.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"vite": "8.1.3",
@@ -79,6 +94,13 @@
"@tree-sitter-grammars/tree-sitter-yaml": false,
"tree-sitter": false,
"core-js-pure": false,
"tree-sitter-json": false
"tree-sitter-json": false,
"msw": false,
"esbuild": false
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+349
View File
@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.14.7'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ClientSpeedTag } from './ClientSpeedTag';
const meta = {
title: 'Clients/ClientSpeedTag',
component: ClientSpeedTag,
tags: ['autodocs'],
} satisfies Meta<typeof ClientSpeedTag>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Active: Story = {
args: { speed: { up: 1_450_000, down: 8_900_000 } },
};
export const Idle: Story = {
args: { speed: { up: 0, down: 0 } },
};
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ConfigBlock from './ConfigBlock';
const meta = {
title: 'Clients/ConfigBlock',
component: ConfigBlock,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof ConfigBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
const sampleLink = 'vless://11112222-3333-4444-5555-666677778888@panel.example.com:443'
+ '?type=ws&security=tls&path=%2Fpath#example-node';
export const Collapsed: Story = {
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
};
export const Expanded: Story = {
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt', defaultOpen: true },
};
export const WithoutQr: Story = {
args: { label: 'trojan', text: sampleLink, fileName: 'client-config.txt', showQr: false, tagColor: 'geekblue' },
};
@@ -0,0 +1,70 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from 'antd';
import PromptModal from './PromptModal';
const meta = {
title: 'Feedback/PromptModal',
component: PromptModal,
tags: ['autodocs'],
} satisfies Meta<typeof PromptModal>;
export default meta;
type Story = StoryObj<typeof meta>;
function InputDemo() {
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
return (
<>
<Button type="primary" onClick={() => setOpen(true)}>Rename client</Button>
<div style={{ marginTop: 12 }}>Last confirmed: {value || '—'}</div>
<PromptModal
open={open}
title="Enter a new name"
initialValue={value}
onClose={() => setOpen(false)}
onConfirm={(next) => {
setValue(next);
setOpen(false);
}}
/>
</>
);
}
function TextareaDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Edit note</Button>
<PromptModal
open={open}
type="textarea"
title="Edit note"
initialValue={'line one\nline two'}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
/>
</>
);
}
const placeholderArgs = {
open: false,
title: '',
onClose: () => undefined,
onConfirm: () => undefined,
};
export const Input: Story = {
args: placeholderArgs,
render: () => <InputDemo />,
};
export const Textarea: Story = {
args: placeholderArgs,
render: () => <TextareaDemo />,
};
@@ -0,0 +1,51 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from 'antd';
import TextModal from './TextModal';
const meta = {
title: 'Feedback/TextModal',
component: TextModal,
tags: ['autodocs'],
} satisfies Meta<typeof TextModal>;
export default meta;
type Story = StoryObj<typeof meta>;
const jsonSample = JSON.stringify({ outbounds: [{ protocol: 'vless', tag: 'proxy' }] }, null, 2);
function Demo({ json, fileName }: { json?: boolean; fileName?: string }) {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Show config</Button>
<TextModal
open={open}
title="Client configuration"
content={json ? jsonSample : 'vless://uuid@example.com:443#node'}
fileName={fileName}
json={json}
onClose={() => setOpen(false)}
/>
</>
);
}
const placeholderArgs = {
open: false,
title: '',
content: '',
onClose: () => undefined,
};
export const PlainText: Story = {
args: placeholderArgs,
render: () => <Demo fileName="client.txt" />,
};
export const Json: Story = {
args: placeholderArgs,
render: () => <Demo json fileName="config.json" />,
};
@@ -0,0 +1,93 @@
import { cloneElement } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
import { Form } from 'antd';
import { useTranslation } from 'react-i18next';
import { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
import { toDotted, type FieldName } from './toDotted';
interface FormFieldTransform {
input?: (value: unknown) => unknown;
output?: (eventValue: unknown) => unknown;
}
export interface FormFieldProps<T extends FieldValues = FieldValues> {
name: FieldName;
control?: Control<T>;
label?: ReactNode;
tooltip?: ReactNode;
extra?: ReactNode;
valueProp?: ValueProp;
transform?: FormFieldTransform;
onAfterChange?: (value: unknown) => void;
rules?: ControllerProps<T>['rules'];
required?: boolean;
noStyle?: boolean;
className?: string;
style?: CSSProperties;
children: ReactElement;
}
export function FormField<T extends FieldValues = FieldValues>({
name,
control,
label,
tooltip,
extra,
valueProp = 'value',
transform,
onAfterChange,
rules,
required,
noStyle,
className,
style,
children,
}: FormFieldProps<T>) {
const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>;
return (
<Controller
control={control}
name={dottedName}
rules={rules}
render={({ field, fieldState }) => {
const displayValue = transform?.input ? transform.input(field.value) : field.value;
const help = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined;
const childProps: Record<string, unknown> = {
[valueProp]: displayValue,
onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp);
const next = transform?.output ? transform.output(raw) : raw;
field.onChange(next);
onAfterChange?.(next);
},
onBlur: field.onBlur,
ref: field.ref,
};
return (
<Form.Item
label={label}
tooltip={tooltip}
extra={extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help}
noStyle={noStyle}
className={className}
style={style}
>
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
</Form.Item>
);
}}
/>
);
}
export default FormField;
@@ -0,0 +1,5 @@
export { FormField, type FormFieldProps } from './FormField';
export { useZodForm } from './useZodForm';
export { rhfZodValidate } from './rhfZodValidate';
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
export { toDotted, type FieldName } from './toDotted';
@@ -0,0 +1,10 @@
export type ValueProp = 'value' | 'checked';
export function normalizeAntdOnChange(args: unknown[], valueProp: ValueProp): unknown {
const first = args[0];
if (first !== null && typeof first === 'object' && 'target' in first) {
const target = (first as { target: { value?: unknown; checked?: unknown } }).target;
return valueProp === 'checked' ? target.checked : target.value;
}
return first;
}
@@ -0,0 +1,9 @@
import type { z } from 'zod';
export function rhfZodValidate<S extends z.ZodType>(schema: S) {
return (value: unknown): string | true => {
const result = schema.safeParse(value);
if (result.success) return true;
return result.error.issues[0]?.message ?? 'validation.invalid';
};
}
@@ -0,0 +1,5 @@
export type FieldName = string | ReadonlyArray<string | number>;
export function toDotted(name: FieldName): string {
return typeof name === 'string' ? name : name.join('.');
}
@@ -0,0 +1,18 @@
import { useForm } from 'react-hook-form';
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
export function useZodForm<TFieldValues extends FieldValues>(
schema: z.ZodType<TFieldValues>,
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
): UseFormReturn<TFieldValues> {
const resolver = zodResolver(schema as z.ZodType<TFieldValues, TFieldValues>) as Resolver<TFieldValues>;
return useForm<TFieldValues>({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldUnregister: false,
...options,
resolver,
});
}
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import InfinityIcon from './InfinityIcon';
const meta = {
title: 'UI/InfinityIcon',
component: InfinityIcon,
tags: ['autodocs'],
} satisfies Meta<typeof InfinityIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Large: Story = {
args: { width: 48, height: 34 },
};
export const InlineWithText: Story = {
render: () => (
<span style={{ fontSize: 16 }}>
Unlimited traffic <InfinityIcon />
</span>
),
};
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Input, Space } from 'antd';
import InputAddon from './InputAddon';
const meta = {
title: 'UI/InputAddon',
component: InputAddon,
tags: ['autodocs'],
} satisfies Meta<typeof InputAddon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Static: Story = {
args: { children: 'https://' },
};
export const Clickable: Story = {
args: { children: 'Copy', ariaLabel: 'Copy value', onClick: () => undefined },
};
export const BesideInput: Story = {
args: { children: 'https://' },
render: () => (
<Space.Compact>
<InputAddon>https://</InputAddon>
<Input defaultValue="panel.example.com" style={{ width: 220 }} />
</Space.Compact>
),
};
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber, Switch } from 'antd';
import SettingListItem from './SettingListItem';
const meta = {
title: 'UI/SettingListItem',
component: SettingListItem,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof SettingListItem>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithSwitch: Story = {
args: {
title: 'Enable subscription',
description: 'Expose an aggregated subscription URL for this client.',
control: <Switch defaultChecked />,
},
};
export const WithNumber: Story = {
args: {
title: 'Traffic limit',
description: 'Cap total traffic in gigabytes. Zero means unlimited.',
control: <InputNumber min={0} defaultValue={100} style={{ width: '100%' }} />,
},
};
export const CompactPadding: Story = {
args: {
paddings: 'small',
title: 'Auto renew',
description: 'Restart the quota window automatically on depletion.',
control: <Switch />,
},
};
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Switch } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { NotificationCard } from './NotificationCard';
const meta = {
title: 'UI/Notifications/NotificationCard',
component: NotificationCard,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof NotificationCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: <BellOutlined />,
title: 'Telegram',
extra: <Switch defaultChecked />,
children: <span>Push a message to the configured chat when an event fires.</span>,
},
};
export const Disabled: Story = {
args: {
icon: <BellOutlined />,
title: 'Email',
extra: <Switch />,
children: <span>Email delivery is turned off for this channel.</span>,
},
};
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Sparkline from './Sparkline';
const wave = Array.from({ length: 48 }, (_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3));
const inverse = wave.map((v) => Math.max(0, 100 - v));
const meta = {
title: 'Viz/Sparkline',
component: Sparkline,
tags: ['autodocs'],
parameters: { layout: 'padded' },
} satisfies Meta<typeof Sparkline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { data: wave, height: 80 },
};
export const AxesAndGrid: Story = {
args: { data: wave, height: 140, showAxes: true, showGrid: true, name1: 'CPU' },
};
export const Extrema: Story = {
args: { data: wave, height: 140, name1: 'CPU', extrema: { show: true } },
};
export const MultiSeriesTooltip: Story = {
args: {
data: wave,
data2: inverse,
name1: 'Upload',
name2: 'Download',
height: 140,
showTooltip: true,
showAxes: true,
},
};
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport';
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
interface FinalMaskFieldProps {
value?: FinalMaskStreamSettings;
onChange?: (next: FinalMaskStreamSettings) => void;
network: string;
protocol: string;
showAll?: boolean;
}
const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] };
export default function FinalMaskField({ value, onChange, network, protocol, showAll }: FinalMaskFieldProps) {
const [form] = Form.useForm();
const [initial] = useState(() => value ?? EMPTY);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const lastEmitted = useRef(JSON.stringify(initial));
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
useEffect(() => {
if (finalmask === undefined) return;
const serialized = JSON.stringify(finalmask);
if (serialized === lastEmitted.current) return;
lastEmitted.current = serialized;
onChangeRef.current?.(finalmask);
}, [finalmask]);
return (
<Form
form={form}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={{ finalmask: initial }}
>
<FinalMaskForm name="finalmask" network={network} protocol={protocol} form={form} showAll={showAll} />
</Form>
);
}
@@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import SniffingFields from '@/lib/xray/forms/SniffingFields';
import type { Sniffing } from '@/schemas/primitives/sniffing';
interface SniffingFieldProps {
value?: Sniffing;
onChange?: (next: Sniffing) => void;
enableLabel: string;
}
export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) {
const [form] = Form.useForm();
const [initial] = useState(() => value ?? ({} as Sniffing));
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const lastEmitted = useRef(JSON.stringify(initial));
const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined;
useEffect(() => {
if (sniffing === undefined) return;
const serialized = JSON.stringify(sniffing);
if (serialized === lastEmitted.current) return;
lastEmitted.current = serialized;
onChangeRef.current?.(sniffing);
}, [sniffing]);
return (
<Form
form={form}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={{ sniffing: initial }}
>
<SniffingFields name={['sniffing']} form={form} enableLabel={enableLabel} />
</Form>
);
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { CustomSockoptList } from '@/components/form';
import type { CustomSockopt } from '@/schemas/protocols/stream/sockopt';
interface SockoptCustomFieldProps {
value?: CustomSockopt[];
onChange?: (next: CustomSockopt[]) => void;
}
export default function SockoptCustomField({ value, onChange }: SockoptCustomFieldProps) {
const [form] = Form.useForm();
const [initial] = useState(() => value ?? []);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const lastEmitted = useRef(JSON.stringify(initial));
const list = Form.useWatch('customSockopt', form) as CustomSockopt[] | undefined;
useEffect(() => {
if (list === undefined) return;
const serialized = JSON.stringify(list);
if (serialized === lastEmitted.current) return;
lastEmitted.current = serialized;
onChangeRef.current?.(list);
}, [list]);
return (
<Form form={form} component={false} initialValues={{ customSockopt: initial }}>
<CustomSockoptList name={['customSockopt']} />
</Form>
);
}
@@ -0,0 +1,3 @@
export { default as FinalMaskField } from './FinalMaskField';
export { default as SniffingField } from './SniffingField';
export { default as SockoptCustomField } from './SockoptCustomField';
@@ -1,6 +1,13 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Form, Modal, message } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
type GroupFormValues = { group: string };
const EMPTY: GroupFormValues = { group: '' };
interface BulkAddToGroupModalProps {
open: boolean;
@@ -19,15 +26,16 @@ export default function BulkAddToGroupModal({
}: BulkAddToGroupModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const methods = useForm<GroupFormValues>({ defaultValues: EMPTY });
const group = useWatch({ control: methods.control, name: 'group' });
useEffect(() => {
if (open) setValue('');
}, [open]);
if (open) methods.reset(EMPTY);
}, [open, methods]);
async function submit() {
const next = value.trim();
const next = (methods.getValues().group ?? '').trim();
if (!next) return;
setSubmitting(true);
try {
@@ -51,26 +59,28 @@ export default function BulkAddToGroupModal({
okText={t('add')}
cancelText={t('cancel')}
confirmLoading={submitting}
okButtonProps={{ disabled: !value.trim() }}
okButtonProps={{ disabled: !(group ?? '').trim() }}
onCancel={() => onOpenChange(false)}
onOk={submit}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item
label={t('pages.clients.group')}
tooltip={t('pages.clients.addToGroupTooltip')}
>
<AutoComplete
value={value}
placeholder={t('pages.clients.groupName')}
options={groups.map((g) => ({ value: g }))}
onChange={(v) => setValue(v ?? '')}
allowClear
autoFocus
/>
</Form.Item>
</Form>
<FormProvider {...methods}>
<Form layout="vertical">
<FormField
name="group"
label={t('pages.clients.group')}
tooltip={t('pages.clients.addToGroupTooltip')}
transform={{ output: (v) => v ?? '' }}
>
<AutoComplete
placeholder={t('pages.clients.groupName')}
options={groups.map((g) => ({ value: g }))}
allowClear
autoFocus
/>
</FormField>
</Form>
</FormProvider>
</Modal>
</>
);
+196 -195
View File
@@ -4,11 +4,13 @@ import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, S
import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { RandomUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { useClients, type InboundOption } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
@@ -19,6 +21,24 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
]);
const EMPTY: ClientBulkAddFormValues = {
emailMethod: 0,
firstNum: 1,
lastNum: 1,
emailPrefix: '',
emailPostfix: '',
quantity: 1,
subId: '',
group: '',
comment: '',
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
reset: 0,
inboundIds: [],
};
interface ClientBulkAddModalProps {
open: boolean;
inbounds: InboundOption[];
@@ -27,28 +47,6 @@ interface ClientBulkAddModalProps {
onSaved?: () => void;
}
type FormState = ClientBulkAddFormValues;
function emptyForm(): FormState {
return {
emailMethod: 0,
firstNum: 1,
lastNum: 1,
emailPrefix: '',
emailPostfix: '',
quantity: 1,
subId: '',
group: '',
comment: '',
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
reset: 0,
inboundIds: [],
};
}
export default function ClientBulkAddModal({
open,
inbounds,
@@ -60,7 +58,14 @@ export default function ClientBulkAddModal({
const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients();
const [form, setForm] = useState<FormState>(emptyForm);
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
const emailMethod = useWatch({ control: methods.control, name: 'emailMethod' });
const firstNum = useWatch({ control: methods.control, name: 'firstNum' });
const flow = useWatch({ control: methods.control, name: 'flow' });
const expiryTime = useWatch({ control: methods.control, name: 'expiryTime' });
const subId = useWatch({ control: methods.control, name: 'subId' });
const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
const [delayedStart, setDelayedStart] = useState(false);
const [saving, setSaving] = useState(false);
const fail2ban = useFail2banStatusQuery();
@@ -70,14 +75,10 @@ export default function ClientBulkAddModal({
useEffect(() => {
if (!open) return;
setForm(emptyForm());
methods.reset(EMPTY);
setDelayedStart(false);
}, [open]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
}, [open, methods]);
const flowCapableIds = useMemo(() => {
const ids = new Set<number>();
@@ -88,25 +89,25 @@ export default function ClientBulkAddModal({
}, [inbounds]);
const showFlow = useMemo(
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
[form.inboundIds, flowCapableIds],
() => (inboundIds || []).some((id) => flowCapableIds.has(id)),
[inboundIds, flowCapableIds],
);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
for (const id of inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
}, [form.inboundIds, inbounds]);
}, [inboundIds, inbounds]);
useEffect(() => {
if (!showFlow && form.flow) {
if (!showFlow && flow) {
update('flow', '');
methods.setValue('flow', '');
}
}, [showFlow, form.flow]);
}, [showFlow, flow, methods]);
const inboundOptions = useMemo(
() => (inbounds || [])
@@ -119,27 +120,27 @@ export default function ClientBulkAddModal({
);
const expiryDate = useMemo<Dayjs | null>(
() => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
[form.expiryTime],
() => (expiryTime > 0 ? dayjs(expiryTime) : null),
[expiryTime],
);
const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0;
const delayedExpireDays = expiryTime < 0 ? expiryTime / -86400000 : 0;
function buildEmails(): string[] {
const method = form.emailMethod;
function buildEmails(values: ClientBulkAddFormValues): string[] {
const method = values.emailMethod;
const out: string[] = [];
let start: number;
let end: number;
if (method > 1) {
start = form.firstNum;
end = form.lastNum + 1;
start = values.firstNum;
end = values.lastNum + 1;
} else {
start = 0;
end = form.quantity;
end = values.quantity;
}
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
const prefix = method > 0 && values.emailPrefix.length > 0 ? values.emailPrefix : '';
const useNum = method > 1;
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
const postfix = method > 2 && values.emailPostfix.length > 0 ? values.emailPostfix : '';
for (let i = start; i < end; i++) {
let email = '';
if (method !== 4) email = RandomUtil.randomLowerAndNum(10);
@@ -150,12 +151,13 @@ export default function ClientBulkAddModal({
}
async function submit() {
const validated = ClientBulkAddFormSchema.safeParse(form);
const current = methods.getValues();
const validated = ClientBulkAddFormSchema.safeParse(current);
if (!validated.success) {
messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
return;
}
const emails = buildEmails();
const emails = buildEmails(current);
if (emails.length === 0) return;
setSaving(true);
@@ -163,22 +165,22 @@ export default function ClientBulkAddModal({
const payloads = emails.map((email) => ({
client: {
email,
subId: form.subId || RandomUtil.randomLowerAndNum(16),
subId: current.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(),
password: ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow ? (form.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: form.expiryTime,
reset: Number(form.reset) || 0,
limitIp: Number(form.limitIp) || 0,
group: form.group,
comment: form.comment,
flow: showFlow ? (current.flow || '') : '',
totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: current.expiryTime,
reset: Number(current.reset) || 0,
limitIp: Number(current.limitIp) || 0,
group: current.group,
comment: current.comment,
enable: true,
},
inboundIds: form.inboundIds,
inboundIds: current.inboundIds,
}));
const msg = await bulkCreate(payloads);
const ok = msg?.obj?.created ?? 0;
@@ -213,157 +215,156 @@ export default function ClientBulkAddModal({
onOk={submit}
onCancel={() => onOpenChange(false)}
>
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
<Form.Item label={t('pages.clients.attachedInbounds')} required>
<SelectAllClearButtons
options={inboundOptions}
value={form.inboundIds}
onChange={(v) => update('inboundIds', v)}
/>
<Select
mode="multiple"
value={form.inboundIds}
onChange={(v) => update('inboundIds', v)}
options={inboundOptions}
placeholder={t('pages.clients.selectInbound')}
showSearch={{
filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}}
/>
</Form.Item>
<Form.Item label={t('pages.clients.method')}>
<Select
value={form.emailMethod}
onChange={(v) => update('emailMethod', v)}
options={[
{ value: 0, label: 'Random' },
{ value: 1, label: 'Random + Prefix' },
{ value: 2, label: 'Random + Prefix + Num' },
{ value: 3, label: 'Random + Prefix + Num + Postfix' },
{ value: 4, label: 'Prefix + Num + Postfix' },
]}
/>
</Form.Item>
{form.emailMethod > 1 && (
<>
<Form.Item label={t('pages.clients.first')}>
<InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
</Form.Item>
<Form.Item label={t('pages.clients.last')}>
<InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
</Form.Item>
</>
)}
{form.emailMethod > 0 && (
<Form.Item label={t('pages.clients.prefix')}>
<Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
</Form.Item>
)}
{form.emailMethod > 2 && (
<Form.Item label={t('pages.clients.postfix')}>
<Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
</Form.Item>
)}
{form.emailMethod < 2 && (
<Form.Item label={t('pages.clients.clientCount')}>
<InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
</Form.Item>
)}
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={form.subId}
onChange={(e) => update('subId', e.target.value)}
style={{ flex: 1 }}
<FormProvider {...methods}>
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
<Form.Item label={t('pages.clients.attachedInbounds')} required>
<SelectAllClearButtons
options={inboundOptions}
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
<AutoComplete
value={form.group}
placeholder={t('pages.clients.groupPlaceholder')}
options={groups.map((g) => ({ value: g }))}
onChange={(v) => update('group', v ?? '')}
allowClear
/>
</Form.Item>
<Form.Item label={t('comment')}>
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
</Form.Item>
{showFlow && (
<Form.Item label={t('pages.clients.flow')}>
<Select
value={form.flow}
onChange={(v) => update('flow', v)}
style={{ width: 220 }}
mode="multiple"
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
options={inboundOptions}
placeholder={t('pages.clients.selectInbound')}
showSearch={{
filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}}
/>
</Form.Item>
<FormField name="emailMethod" label={t('pages.clients.method')}>
<Select
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
{ value: 0, label: 'Random' },
{ value: 1, label: 'Random + Prefix' },
{ value: 2, label: 'Random + Prefix + Num' },
{ value: 3, label: 'Random + Prefix + Num + Postfix' },
{ value: 4, label: 'Prefix + Num + Postfix' },
]}
/>
</FormField>
{emailMethod > 1 && (
<>
<FormField name="firstNum" label={t('pages.clients.first')} transform={{ output: (v) => Number(v) || 1 }}>
<InputNumber min={1} />
</FormField>
<FormField name="lastNum" label={t('pages.clients.last')} transform={{ output: (v) => Number(v) || 1 }}>
<InputNumber min={firstNum} />
</FormField>
</>
)}
{emailMethod > 0 && (
<FormField name="emailPrefix" label={t('pages.clients.prefix')}>
<Input />
</FormField>
)}
{emailMethod > 2 && (
<FormField name="emailPostfix" label={t('pages.clients.postfix')}>
<Input />
</FormField>
)}
{emailMethod < 2 && (
<FormField name="quantity" label={t('pages.clients.clientCount')} transform={{ output: (v) => Number(v) || 1 }}>
<InputNumber min={1} max={1000} />
</FormField>
)}
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={subId}
onChange={(e) => methods.setValue('subId', e.target.value)}
style={{ flex: 1 }}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
/>
</Space.Compact>
</Form.Item>
)}
<Form.Item label={t('pages.clients.limitIp')}>
<Tooltip title={limitIpNotice || undefined}>
<span style={{ display: 'inline-flex' }}>
<InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled}
style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
onChange={(v) => update('limitIp', Number(v) || 0)} />
</span>
</Tooltip>
</Form.Item>
<FormField
name="group"
label={t('pages.clients.group')}
tooltip={t('pages.clients.groupDesc')}
transform={{ output: (v) => v ?? '' }}
>
<AutoComplete
placeholder={t('pages.clients.groupPlaceholder')}
options={groups.map((g) => ({ value: g }))}
allowClear
/>
</FormField>
<Form.Item label={t('pages.clients.totalGB')}>
<InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />
</Form.Item>
<FormField name="comment" label={t('comment')}>
<Input />
</FormField>
<Form.Item label={t('pages.clients.delayedStart')}>
<Switch
checked={delayedStart}
onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
/>
</Form.Item>
{showFlow && (
<FormField name="flow" label={t('pages.clients.flow')}>
<Select
style={{ width: 220 }}
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</FormField>
)}
{delayedStart ? (
<Form.Item label={t('pages.clients.expireDays')}>
<InputNumber
value={delayedExpireDays}
min={0}
onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))}
<Form.Item label={t('pages.clients.limitIp')}>
<Tooltip title={limitIpNotice || undefined}>
<span style={{ display: 'inline-flex' }}>
<InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
</span>
</Tooltip>
</Form.Item>
<FormField name="totalGB" label={t('pages.clients.totalGB')} transform={{ output: (v) => Number(v) || 0 }}>
<InputNumber min={0} step={1} />
</FormField>
<Form.Item label={t('pages.clients.delayedStart')}>
<Switch
checked={delayedStart}
onClick={() => { setDelayedStart(!delayedStart); methods.setValue('expiryTime', 0); }}
/>
</Form.Item>
) : (
<Form.Item label={t('pages.inbounds.expireDate')}>
<DateTimePicker
value={expiryDate}
onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
/>
</Form.Item>
)}
<Form.Item
label={t('pages.clients.renew')}
tooltip={t('pages.clients.renewDesc')}
>
<InputNumber
value={form.reset}
min={0}
onChange={(v) => update('reset', Number(v) || 0)}
/>
</Form.Item>
</Form>
{delayedStart ? (
<Form.Item label={t('pages.clients.expireDays')}>
<InputNumber
value={delayedExpireDays}
min={0}
onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
/>
</Form.Item>
) : (
<Form.Item label={t('pages.inbounds.expireDate')}>
<DateTimePicker
value={expiryDate}
onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)}
/>
</Form.Item>
)}
<FormField
name="reset"
label={t('pages.clients.renew')}
tooltip={t('pages.clients.renewDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} />
</FormField>
</Form>
</FormProvider>
</Modal>
</>
);
@@ -1,14 +1,18 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
import { FormProvider, useForm } from 'react-hook-form';
import { ClientBulkAdjustFormSchema } from '@/schemas/client';
import { ClientBulkAdjustFormSchema, type ClientBulkAdjustFormValues } from '@/schemas/client';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
import { FormField } from '@/components/form/rhf';
const GB = 1024 * 1024 * 1024;
const FLOW_CLEAR = 'none';
const EMPTY: ClientBulkAdjustFormValues = { addDays: 0, addGB: 0, flow: '' };
interface ClientBulkAdjustModalProps {
open: boolean;
count: number;
@@ -19,24 +23,19 @@ interface ClientBulkAdjustModalProps {
export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [addDays, setAddDays] = useState<number>(0);
const [addGB, setAddGB] = useState<number>(0);
const [flow, setFlow] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const methods = useForm<ClientBulkAdjustFormValues>({ defaultValues: EMPTY });
useEffect(() => {
if (open) {
setAddDays(0);
setAddGB(0);
setFlow('');
}
}, [open]);
if (open) methods.reset(EMPTY);
}, [open, methods]);
async function handleOk() {
const values = methods.getValues();
const validated = ClientBulkAdjustFormSchema.safeParse({
addDays: Math.trunc(Number(addDays) || 0),
addGB: Number(addGB) || 0,
flow,
addDays: Math.trunc(Number(values.addDays) || 0),
addGB: Number(values.addGB) || 0,
flow: values.flow,
});
if (!validated.success) {
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
@@ -83,37 +82,26 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
style={{ marginBottom: 16 }}
title={t('pages.clients.bulkAdjustHint')}
/>
<Form layout="vertical">
<Form.Item label={t('pages.clients.addDays')}>
<InputNumber
value={addDays}
onChange={(v) => setAddDays(Number(v) || 0)}
style={{ width: '100%' }}
step={1}
precision={0}
/>
</Form.Item>
<Form.Item label={t('pages.clients.addTrafficGB')}>
<InputNumber
value={addGB}
onChange={(v) => setAddGB(Number(v) || 0)}
style={{ width: '100%' }}
step={1}
/>
</Form.Item>
<Form.Item label={t('pages.clients.bulkFlow')}>
<Select
value={flow}
onChange={setFlow}
style={{ width: '100%' }}
options={[
{ value: '', label: t('pages.clients.bulkFlowNoChange') },
{ value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Form>
<FormProvider {...methods}>
<Form layout="vertical">
<FormField name="addDays" label={t('pages.clients.addDays')}>
<InputNumber style={{ width: '100%' }} step={1} precision={0} />
</FormField>
<FormField name="addGB" label={t('pages.clients.addTrafficGB')}>
<InputNumber style={{ width: '100%' }} step={1} />
</FormField>
<FormField name="flow" label={t('pages.clients.bulkFlow')}>
<Select
style={{ width: '100%' }}
options={[
{ value: '', label: t('pages.clients.bulkFlowNoChange') },
{ value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
]}
/>
</FormField>
</Form>
</FormProvider>
</Modal>
</>
);
File diff suppressed because it is too large Load Diff
+226 -209
View File
@@ -11,18 +11,22 @@ import {
DeploymentUnitOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import type { HostFormValues } from '@/schemas/api/host';
import { HostFormSchema, type HostFormValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
// inboundId is optional in the form so a new host starts unselected (the Select
// shows its placeholder instead of 0); the required rule enforces it on submit.
/*
* inboundId is optional in the form so a new host starts unselected (the Select
* shows its placeholder instead of 0); the required rule enforces it on submit.
*/
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
interface HostFormModalProps {
@@ -74,19 +78,21 @@ function defaultsFor(host: HostRecord | null): FormShape {
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [form] = Form.useForm<FormShape>();
const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
// Drive conditional field visibility off the selected security, like the
// legacy externalProxy form: same/none inherit fully and hide every TLS/cert
// field; reality shows only the reality-relevant subset (its keys are
// inherited from the inbound); tls shows the full TLS override set.
const security = (Form.useWatch('security', form) ?? 'same') as string;
/*
* Drive conditional field visibility off the selected security, like the
* legacy externalProxy form: same/none inherit fully and hide every TLS/cert
* field; reality shows only the reality-relevant subset (its keys are
* inherited from the inbound); tls shows the full TLS override set.
*/
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
const showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls';
useEffect(() => {
if (open) form.setFieldsValue(defaultsFor(host));
}, [open, host, form]);
if (open) methods.reset(defaultsFor(host));
}, [open, host, methods]);
const { nodes } = useNodesQuery();
@@ -108,13 +114,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
const onOk = async () => {
let values: FormShape;
try {
values = await form.validateFields();
} catch {
return;
}
const onFinish = async (values: FormShape) => {
const { enable, ...rest } = values;
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
const res = await save(payload);
@@ -130,7 +130,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
<Modal
open={open}
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
onOk={onOk}
onOk={methods.handleSubmit(onFinish)}
onCancel={() => onOpenChange(false)}
okText={t('save')}
cancelText={t('cancel')}
@@ -138,198 +138,215 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
>
<Form
form={form}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={defaultsFor(host)}
preserve={false}
>
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
forceRender: true,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
<Input maxLength={256} />
</Form.Item>
<Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<Input maxLength={64} />
</Form.Item>
<Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
<Select
options={inboundSelectOptions}
showSearch
optionFilterProp="label"
disabled={mode === 'edit'}
placeholder={t('pages.hosts.selectInbound')}
/>
</Form.Item>
<Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
<Input placeholder="cdn.example.com" />
</Form.Item>
<Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<InputNumber min={0} max={65535} />
</Form.Item>
<Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</Form.Item>
<Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
</Form.Item>
<Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
<Switch />
</Form.Item>
</>
),
},
{
key: 'security',
forceRender: true,
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
children: (
<>
<Form.Item name="security" label={t('pages.hosts.fields.security')}>
<Select
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
{showTls && (
<>
<Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
</Form.Item>
<Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
<Select allowClear options={fpOptions} />
</Form.Item>
</>
)}
{showTlsExtras && (
<>
<Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} />
</Form.Item>
<Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</Form.Item>
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
<Input placeholder="example.com" />
</Form.Item>
<Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
<Input.TextArea rows={2} />
</Form.Item>
</>
)}
</>
),
},
{
key: 'advanced',
forceRender: true,
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
children: (
<Tabs
size="small"
defaultActiveKey="adv-general"
items={[
{
key: 'adv-general',
forceRender: true,
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
children: (
<>
<Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
<Input />
</Form.Item>
<Form.Item name="path" label={t('pages.hosts.fields.path')}>
<Input />
</Form.Item>
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<Input placeholder="443" />
</Form.Item>
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<Select
mode="multiple"
allowClear
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
>
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
forceRender: true,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.remark) }}>
<Input maxLength={256} />
</FormField>
<FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<Input maxLength={64} />
</FormField>
<FormField name="inboundId" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.inboundId) }}>
<Select
options={inboundSelectOptions}
showSearch
optionFilterProp="label"
disabled={mode === 'edit'}
placeholder={t('pages.hosts.selectInbound')}
/>
</FormField>
<FormField name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
<Input placeholder="cdn.example.com" />
</FormField>
<FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<InputNumber min={0} max={65535} />
</FormField>
<FormField name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
</FormField>
<FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
<Switch />
</FormField>
</>
),
},
{
key: 'security',
forceRender: true,
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
children: (
<>
<FormField name="security" label={t('pages.hosts.fields.security')}>
<Select
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
/>
</FormField>
{showTls && (
<>
<FormField name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
</FormField>
<FormField name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valueProp="checked">
<Switch />
</FormField>
<FormField name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valueProp="checked">
<Switch />
</FormField>
<FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
<Select allowClear options={fpOptions} />
</FormField>
</>
)}
{showTlsExtras && (
<>
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} />
</FormField>
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
<Input placeholder="example.com" />
</FormField>
<FormField name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valueProp="checked">
<Switch />
</FormField>
<FormField name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
<Input.TextArea rows={2} />
</FormField>
</>
)}
</>
),
},
{
key: 'advanced',
forceRender: true,
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
children: (
<Tabs
size="small"
defaultActiveKey="adv-general"
items={[
{
key: 'adv-general',
forceRender: true,
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
children: (
<>
<FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
<Input />
</FormField>
<FormField name="path" label={t('pages.hosts.fields.path')}>
<Input />
</FormField>
<FormField name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<Input placeholder="443" />
</FormField>
<FormField name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<Select
mode="multiple"
allowClear
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
/>
</FormField>
</>
),
},
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="muxParams"
render={({ field }) => (
<HostMuxForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
</>
),
},
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
children: (
<Form.Item name="muxParams" noStyle>
<HostMuxForm />
</Form.Item>
),
},
{
key: 'adv-sockopt',
forceRender: true,
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
children: (
<Form.Item name="sockoptParams" noStyle>
<HostSockoptForm />
</Form.Item>
),
},
{
key: 'adv-finalmask',
forceRender: true,
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
children: (
<Form.Item name="finalMask" noStyle>
<HostFinalMaskForm />
</Form.Item>
),
},
]}
/>
),
},
{
key: 'clash',
forceRender: true,
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
children: (
<>
<Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
<Select
allowClear
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
<Switch />
</Form.Item>
</>
),
},
]}
/>
</Form>
),
},
{
key: 'adv-sockopt',
forceRender: true,
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="sockoptParams"
render={({ field }) => (
<HostSockoptForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
),
},
{
key: 'adv-finalmask',
forceRender: true,
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="finalMask"
render={({ field }) => (
<HostFinalMaskForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
),
},
]}
/>
),
},
{
key: 'clash',
forceRender: true,
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
children: (
<>
<FormField name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
<Select
allowClear
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
/>
</FormField>
<FormField name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valueProp="checked">
<Switch />
</FormField>
<FormField name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valueProp="checked">
<Switch />
</FormField>
</>
),
},
]}
/>
</Form>
</FormProvider>
</Modal>
);
}
@@ -3,10 +3,12 @@ import { MuxForm } from '@/pages/xray/outbounds/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
// settings editor). Stored in the host's muxParams JSON string. Defaults match
// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
// when the toggle is off, an explicit mux object when on.
/*
* Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
* settings editor). Stored in the host's muxParams JSON string. Defaults match
* the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
* when the toggle is off, an explicit mux object when on.
*/
const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
@@ -17,9 +19,9 @@ export default function HostMuxForm({ value, onChange }: { value?: string; onCha
path={['mux']}
defaultSubtree={DEFAULT_MUX}
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
// protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
// a host's mux override is protocol-agnostic and should always be editable.
render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />}
/* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
a host's mux override is protocol-agnostic and should always be editable. */
render={() => <MuxForm protocol="vmess" network="tcp" />}
/>
);
}
@@ -4,16 +4,18 @@ import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
// Sockopt override editor — reuses the outbound SockoptForm (which carries its
// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
// sockoptParams JSON string.
//
// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
// from the output. Verified against xray-core transport/internet/sockopt_*.go:
// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
// on the outbound/dialer socket, so they stay. The outbound form no longer shows
// the inbound-only keys, but its default object still seeds them, so strip here.
/*
* Sockopt override editor — reuses the outbound SockoptForm (which carries its
* own enable Switch and writes streamSettings.sockopt). Serialized to the host's
* sockoptParams JSON string.
*
* A host is the client/dialer side, so the inbound-only sockopt keys are dropped
* from the output. Verified against xray-core transport/internet/sockopt_*.go:
* only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
* are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
* on the outbound/dialer socket, so they stay. The outbound form no longer shows
* the inbound-only keys, but its default object still seeds them, so strip here.
*/
const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
function serializeClientSockopt(sockopt: unknown): string {
@@ -24,11 +26,13 @@ function serializeClientSockopt(sockopt: unknown): string {
}
export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
// Populate the dialerProxy dropdown with the panel's outbound tags (a host can
// chain through one of the subscription's outbounds by tag). dialerProxy chains
// through a single outbound, so balancers (routing targets) are excluded — only
// the outbound group is used; blackhole is dropped too (chaining to it just
// drops the traffic).
/*
* Populate the dialerProxy dropdown with the panel's outbound tags (a host can
* chain through one of the subscription's outbounds by tag). dialerProxy chains
* through a single outbound, so balancers (routing targets) are excluded — only
* the outbound group is used; blackhole is dropped too (chaining to it just
* drops the traffic).
*/
const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
const outboundTags = tagGroups?.outbounds ?? [];
return (
@@ -37,7 +41,7 @@ export default function HostSockoptForm({ value, onChange }: { value?: string; o
onChange={onChange}
path={['streamSettings', 'sockopt']}
serialize={serializeClientSockopt}
render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />}
render={() => <SockoptForm outboundTags={outboundTags} />}
/>
);
}
@@ -1,29 +1,32 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Form, type FormInstance } from 'antd';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { Form } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { FieldValues } from 'react-hook-form';
import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
interface OutboundSubtreeJsonFormProps {
value?: string;
onChange?: (next: string) => void;
// Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
/* Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux']. */
path: (string | number)[];
// Renders the reused outbound form given this wrapper's own form instance.
render: (form: FormInstance<OutboundFormValues>) => ReactNode;
// Seeds the form when the stored value is empty, so toggling a section on
// pre-fills sensible defaults instead of blanks (used by Mux).
/* Renders the reused outbound form, which binds to this wrapper's RHF context. */
render: () => ReactNode;
/* Seeds the form when the stored value is empty, so toggling a section on
pre-fills sensible defaults instead of blanks (used by Mux). */
defaultSubtree?: Record<string, unknown>;
// Turns the edited subtree into the stored JSON string (default: prune empties).
// Mux overrides this to store '' (= inherit) when its enable flag is off.
/* Turns the edited subtree into the stored JSON string (default: prune empties).
Mux overrides this to store '' (= inherit) when its enable flag is off. */
serialize?: (subtree: unknown) => string;
}
// Hosts the reused outbound transport forms (which bind to fixed form paths)
// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
// from the JSON string, watch the edited subtree, and report a JSON string back
// to the parent host form. component={false} avoids a nested <form> DOM node.
/*
* Hosts the reused outbound transport forms (which bind to fixed RHF paths)
* inside an isolated RHF form, mirroring the sub-JSON adapters: seed the form
* from the JSON string, watch the edited subtree, and report a JSON string back
* to the parent host form. The antd Form is layout-only (component={false}
* avoids a nested <form> DOM node); data binding runs through the RHF provider.
*/
export default function OutboundSubtreeJsonForm({
value = '',
onChange,
@@ -32,37 +35,38 @@ export default function OutboundSubtreeJsonForm({
defaultSubtree,
serialize = serializeOverride,
}: OutboundSubtreeJsonFormProps) {
const [form] = Form.useForm();
const [initial] = useState<Record<string, unknown>>(() => {
const parsed = parseJsonObject(value);
return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
});
const [defaultValues] = useState<FieldValues>(() => {
const hasInitial = Object.keys(initial).length > 0;
return nestAtPath(path, hasInitial ? initial : undefined) as FieldValues;
});
const methods = useForm<FieldValues>({ defaultValues });
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const subtree = Form.useWatch(path, form);
const subtree = useWatch({ control: methods.control, name: path.join('.') });
useEffect(() => {
const next = serialize(subtree);
if (next !== value) onChangeRef.current?.(next);
// serialize is logically stable; re-run only when the edited subtree changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
/* serialize is logically stable; re-run only when the edited subtree changes. */
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [subtree, value]);
const hasInitial = Object.keys(initial).length > 0;
const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
return (
<Form
form={form}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={initialValues}
>
{render(form as unknown as FormInstance<OutboundFormValues>)}
</Form>
<FormProvider {...methods}>
<Form
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
>
{render()}
</Form>
</FormProvider>
);
}
@@ -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 */
}
}
};
+57 -54
View File
@@ -22,8 +22,9 @@ import {
UserOutlined,
} from '@ant-design/icons';
import { FormProvider, useForm } from 'react-hook-form';
import { HttpUtil, LanguageManager } from '@/utils';
import { antdRule } from '@/utils/zodForm';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
@@ -48,6 +49,7 @@ export default function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0);
const methods = useForm<LoginForm>({ defaultValues: { username: '', password: '', twoFactorCode: '' } });
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const headlineWords = useMemo(
@@ -180,66 +182,67 @@ export default function LoginPage() {
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
</h2>
<Form
layout="vertical"
className="login-form"
onFinish={onSubmit}
initialValues={{ username: '', password: '', twoFactorCode: '' }}
>
<Form.Item
label={t('username')}
name="username"
rules={[antdRule(LoginFormSchema.shape.username, t)]}
<FormProvider {...methods}>
<Form
layout="vertical"
className="login-form"
onFinish={methods.handleSubmit(onSubmit)}
>
<Input
prefix={<UserOutlined />}
autoComplete="username"
size="large"
placeholder={t('username')}
autoFocus
/>
</Form.Item>
<Form.Item
label={t('password')}
name="password"
rules={[antdRule(LoginFormSchema.shape.password, t)]}
>
<Input.Password
prefix={<LockOutlined />}
autoComplete="current-password"
size="large"
placeholder={t('password')}
/>
</Form.Item>
{twoFactorEnable && (
<Form.Item
label={t('twoFactorCode')}
name="twoFactorCode"
rules={[antdRule(TwoFactorCodeSchema, t)]}
<FormField
name="username"
label={t('username')}
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
>
<Input
prefix={<KeyOutlined />}
autoComplete="one-time-code"
prefix={<UserOutlined />}
autoComplete="username"
size="large"
placeholder={t('twoFactorCode')}
placeholder={t('username')}
autoFocus
/>
</Form.Item>
)}
</FormField>
<Form.Item className="submit-row">
<Button
type="primary"
htmlType="submit"
loading={submitting}
size="large"
block
<FormField
name="password"
label={t('password')}
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
>
{t('login')}
</Button>
</Form.Item>
</Form>
<Input.Password
prefix={<LockOutlined />}
autoComplete="current-password"
size="large"
placeholder={t('password')}
/>
</FormField>
{twoFactorEnable && (
<FormField
name="twoFactorCode"
label={t('twoFactorCode')}
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
>
<Input
prefix={<KeyOutlined />}
autoComplete="one-time-code"
size="large"
placeholder={t('twoFactorCode')}
/>
</FormField>
)}
<Form.Item className="submit-row">
<Button
type="primary"
htmlType="submit"
loading={submitting}
size="large"
block
>
{t('login')}
</Button>
</Form.Item>
</Form>
</FormProvider>
</div>
)}
</div>
+225 -237
View File
@@ -13,11 +13,12 @@ import {
Switch,
message,
} from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { antdRule } from '@/utils/zodForm';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import './NodeFormModal.css';
@@ -65,7 +66,7 @@ export default function NodeFormModal({
onOpenChange,
}: NodeFormModalProps) {
const { t } = useTranslation();
const [form] = Form.useForm<NodeFormValues>();
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
const [messageApi, messageContextHolder] = message.useMessage();
const [submitting, setSubmitting] = useState(false);
@@ -74,9 +75,9 @@ export default function NodeFormModal({
const [fetchingInbounds, setFetchingInbounds] = useState(false);
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const scheme = Form.useWatch('scheme', form) ?? 'https';
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -109,11 +110,10 @@ export default function NodeFormModal({
}
: base;
if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
form.resetFields();
form.setFieldsValue(next);
methods.reset(next);
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
setTestResult(null);
}, [open, mode, node, form]);
}, [open, mode, node, methods]);
const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -141,15 +141,11 @@ export default function NodeFormModal({
}
async function onTest() {
try {
await form.validateFields(['address', 'port']);
} catch {
return;
}
if (!(await methods.trigger(['address', 'port']))) return;
setTesting(true);
setTestResult(null);
try {
const payload = buildPayload(form.getFieldsValue(true));
const payload = buildPayload(methods.getValues());
const msg = await testConnection(payload);
if (msg?.success && msg.obj) {
setTestResult(msg.obj);
@@ -162,17 +158,13 @@ export default function NodeFormModal({
}
async function onFetchPin() {
try {
await form.validateFields(['address', 'port']);
} catch {
return;
}
if (!(await methods.trigger(['address', 'port']))) return;
setFetchingPin(true);
try {
const payload = buildPayload(form.getFieldsValue(true));
const payload = buildPayload(methods.getValues());
const msg = await fetchFingerprint(payload);
if (msg?.success && msg.obj) {
form.setFieldValue('pinnedCertSha256', msg.obj);
methods.setValue('pinnedCertSha256', msg.obj);
messageApi.success(t('pages.nodes.pinFetched'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
@@ -183,14 +175,10 @@ export default function NodeFormModal({
}
async function onFetchInbounds() {
try {
await form.validateFields(['name', 'address', 'port', 'apiToken']);
} catch {
return;
}
if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return;
setFetchingInbounds(true);
try {
const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
const msg = await fetchInbounds(buildPayload(methods.getValues()));
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(msg.obj);
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
@@ -242,229 +230,229 @@ export default function NodeFormModal({
cancelText={t('cancel')}
mask={{ closable: false }}
width="640px"
onOk={() => form.submit()}
onOk={methods.handleSubmit(onFinish)}
onCancel={close}
>
<Form
form={form}
layout="vertical"
initialValues={defaultValues()}
onFinish={onFinish}
>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
label={t('pages.nodes.name')}
name="name"
rules={[antdRule(NodeFormSchema.shape.name, t)]}
>
<Input placeholder={t('pages.nodes.namePlaceholder')} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.nodes.remark')} name="remark">
<Input />
</Form.Item>
</Col>
</Row>
<FormProvider {...methods}>
<Form layout="vertical">
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.name')}
name="name"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.name) }}
>
<Input placeholder={t('pages.nodes.namePlaceholder')} />
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.remark')} name="remark">
<Input />
</FormField>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={6}>
<Form.Item label={t('pages.nodes.scheme')} name="scheme">
<Select
options={[
{ value: 'https', label: 'https' },
{ value: 'http', label: 'http' },
]}
onChange={(value) => {
if (value === 'http') form.setFieldValue('tlsVerifyMode', 'skip');
<Row gutter={16}>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.scheme')}
name="scheme"
onAfterChange={(value) => {
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
}}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
label={t('pages.nodes.address')}
name="address"
rules={[antdRule(NodeFormSchema.shape.address, t)]}
>
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item
label={t('pages.nodes.port')}
name="port"
rules={[antdRule(NodeFormSchema.shape.port, t)]}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
>
<Select
options={[
{ value: 'https', label: 'https' },
{ value: 'http', label: 'http' },
]}
/>
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.address')}
name="address"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
>
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
</FormField>
</Col>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.port')}
name="port"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.nodes.basePath')} name="basePath">
<Input placeholder="/" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
label={t('pages.nodes.enable')}
name="enable"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.basePath')} name="basePath">
<Input placeholder="/" />
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.enable')}
name="enable"
valueProp="checked"
>
<Switch />
</FormField>
</Col>
</Row>
<Form.Item
label={t('pages.nodes.allowPrivateAddress')}
name="allowPrivateAddress"
valuePropName="checked"
tooltip={t('pages.nodes.allowPrivateAddressHint')}
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
tooltip={t('pages.nodes.tlsVerifyModeHint')}
>
<Select
disabled={scheme === 'http'}
options={[
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
]}
/>
</Form.Item>
{tlsVerifyMode === 'skip' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.tlsSkipWarning')}
/>
)}
{tlsVerifyMode === 'mtls' && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.mtlsFormHint')}
/>
)}
{tlsVerifyMode === 'pin' && (
<Form.Item
label={t('pages.nodes.pinnedCert')}
name="pinnedCertSha256"
tooltip={t('pages.nodes.pinnedCertHint')}
<FormField
label={t('pages.nodes.allowPrivateAddress')}
name="allowPrivateAddress"
valueProp="checked"
tooltip={t('pages.nodes.allowPrivateAddressHint')}
>
<Input.Search
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
enterButton={t('pages.nodes.fetchPin')}
loading={fetchingPin}
onSearch={onFetchPin}
/>
</Form.Item>
)}
<Switch />
</FormField>
<Form.Item
label={t('pages.nodes.apiToken')}
name="apiToken"
rules={[antdRule(NodeFormSchema.shape.apiToken, t)]}
tooltip={t('pages.nodes.apiTokenHint')}
>
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.nodes.outboundTag')}
name="outboundTag"
tooltip={t('pages.nodes.outboundTagHint')}
getValueProps={(v) => ({ value: (v as string) || undefined })}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={outboundOptions}
/>
</Form.Item>
<Form.Item
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
tooltip={t('pages.nodes.inboundSyncModeHint')}
>
<Select
options={[
{ value: 'all', label: t('pages.nodes.allInbounds') },
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
]}
/>
</Form.Item>
{inboundSyncMode === 'selected' && (
<Form.Item
label={t('pages.nodes.inboundTags')}
name="inboundTags"
tooltip={t('pages.nodes.inboundTagsHint')}
<FormField
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
tooltip={t('pages.nodes.tlsVerifyModeHint')}
>
<Select
mode="multiple"
allowClear
loading={fetchingInbounds}
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
popupRender={(menu) => (
<>
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
{t('pages.nodes.loadInbounds')}
</Button>
{menu}
</>
)}
options={inboundOptions.map((inbound) => ({
value: inbound.tag,
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
}))}
disabled={scheme === 'http'}
options={[
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
]}
/>
</Form.Item>
)}
</FormField>
<div className="test-row">
<Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')}
</Button>
{testResult && (
<div className="test-result">
{testResult.status === 'online' ? (
<Alert
type="success"
showIcon
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
/>
) : (
<Alert
type="error"
showIcon
title={t('pages.nodes.connectionFailed')}
description={testResult.error}
/>
)}
</div>
{tlsVerifyMode === 'skip' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.tlsSkipWarning')}
/>
)}
</div>
</Form>
{tlsVerifyMode === 'mtls' && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.mtlsFormHint')}
/>
)}
{tlsVerifyMode === 'pin' && (
<FormField
label={t('pages.nodes.pinnedCert')}
name="pinnedCertSha256"
tooltip={t('pages.nodes.pinnedCertHint')}
>
<Input.Search
placeholder={t('pages.nodes.pinnedCertPlaceholder')}
enterButton={t('pages.nodes.fetchPin')}
loading={fetchingPin}
onSearch={onFetchPin}
/>
</FormField>
)}
<FormField
label={t('pages.nodes.apiToken')}
name="apiToken"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
tooltip={t('pages.nodes.apiTokenHint')}
>
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</FormField>
<FormField
label={t('pages.nodes.outboundTag')}
name="outboundTag"
tooltip={t('pages.nodes.outboundTagHint')}
transform={{ input: (v) => (v as string) || undefined }}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={outboundOptions}
/>
</FormField>
<FormField
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
tooltip={t('pages.nodes.inboundSyncModeHint')}
>
<Select
options={[
{ value: 'all', label: t('pages.nodes.allInbounds') },
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
]}
/>
</FormField>
{inboundSyncMode === 'selected' && (
<FormField
label={t('pages.nodes.inboundTags')}
name="inboundTags"
tooltip={t('pages.nodes.inboundTagsHint')}
>
<Select
mode="multiple"
allowClear
loading={fetchingInbounds}
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
popupRender={(menu) => (
<>
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
{t('pages.nodes.loadInbounds')}
</Button>
{menu}
</>
)}
options={inboundOptions.map((inbound) => ({
value: inbound.tag,
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
}))}
/>
</FormField>
)}
<div className="test-row">
<Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')}
</Button>
{testResult && (
<div className="test-result">
{testResult.status === 'online' ? (
<Alert
type="success"
showIcon
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
/>
) : (
<Alert
type="error"
showIcon
title={t('pages.nodes.connectionFailed')}
description={testResult.error}
/>
)}
</div>
)}
</div>
</Form>
</FormProvider>
</Modal>
</>
);
@@ -1,16 +1,18 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { Path } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import {
BalancerFormSchema,
type BalancerFormValues,
} from '@/schemas/xray';
import {
BalancerStrategyTypeSchema,
type BalancerStrategySettings,
type BalancerStrategyType,
} from '@/schemas/routing';
@@ -37,15 +39,7 @@ const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
label: STRATEGY_LABELS[value] ?? value,
}));
interface FormState {
tag: string;
strategy: BalancerStrategyType;
selector: string[];
fallbackTag: string;
settings?: BalancerStrategySettings;
}
function initialState(balancer: BalancerFormValue | null): FormState {
function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
if (!balancer) {
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
}
@@ -67,64 +61,46 @@ export default function BalancerFormModal({
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
const [state, setState] = useState<FormState>(() => initialState(balancer));
const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null;
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
setState((prev) => ({ ...prev, [key]: value }));
};
const parsed = useMemo(
() => BalancerFormSchema.safeParse(state),
[state],
);
const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim());
const issues = useMemo(() => {
const map: Record<string, string> = {};
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? '');
if (!map[key]) map[key] = t(issue.message, { defaultValue: issue.message });
}
useEffect(() => {
if (open) {
methods.reset(initialState(balancer));
setSubmitAttempted(false);
}
return map;
}, [parsed, t]);
}, [open, balancer, methods]);
const showTagIssue = submitAttempted || !!touched.tag;
const showSelectorIssue = submitAttempted || !!touched.selector;
const tagError = showTagIssue ? issues.tag : '';
const selectorError = showSelectorIssue ? issues.selector : '';
const showDuplicate = showTagIssue && duplicateTag;
const strategy = useWatch({ control: methods.control, name: 'strategy' });
const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
function submit() {
const values = methods.getValues();
const parsed = BalancerFormSchema.safeParse(values);
const trimmedTag = (values.tag ?? '').trim();
const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag);
methods.clearErrors();
if (!parsed.success) {
const seen = new Set<string>();
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? '');
if (key && !seen.has(key)) {
seen.add(key);
methods.setError(key as Path<BalancerFormValues>, { message: issue.message });
}
}
}
if (!parsed.success || duplicateTag) {
setSubmitAttempted(true);
return;
}
const values = { ...parsed.data };
if (values.strategy !== 'leastLoad') delete values.settings;
onConfirm(values);
const result: BalancerFormValues = { ...parsed.data };
if (result.strategy !== 'leastLoad') delete result.settings;
onConfirm(result);
}
const settings = state.settings;
const updateSetting = <K extends keyof BalancerStrategySettings>(
key: K,
value: BalancerStrategySettings[K],
) => {
setState((prev) => ({
...prev,
settings: { ...(prev.settings ?? {}), [key]: value },
}));
};
const updateBaselines = (next: string[]) => updateSetting('baselines', next);
const updateCosts = (next: NonNullable<BalancerStrategySettings['costs']>) => updateSetting('costs', next);
const baselines = settings?.baselines ?? [];
const costs = settings?.costs ?? [];
const fallbackOptions = useMemo(
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
[outboundTags, t],
@@ -145,141 +121,144 @@ export default function BalancerFormModal({
onOk={submit}
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item
label={t('pages.xray.balancer.tag')}
required
validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''}
help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
hasFeedback
>
<Input
value={state.tag}
onChange={(e) => update('tag', e.target.value)}
placeholder={t('pages.xray.balancer.tagPlaceholder')}
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Controller
control={methods.control}
name="tag"
render={({ field, fieldState }) => {
const trimmed = (field.value ?? '').trim();
const duplicate = !!trimmed && otherTags.includes(trimmed);
const errorMessage = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: '';
const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
return (
<Form.Item
label={t('pages.xray.balancer.tag')}
required
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
help={errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
hasFeedback
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.balancer.tagPlaceholder')}
/>
</Form.Item>
);
}}
/>
</Form.Item>
<Form.Item label={t('pages.xray.balancer.balancerStrategy')}>
<Select
value={state.strategy}
onChange={(v) => update('strategy', v)}
options={STRATEGIES}
/>
</Form.Item>
<Form.Item
label={t('pages.xray.balancer.selector')}
required
validateStatus={selectorError ? 'error' : ''}
help={selectorError || ''}
hasFeedback
>
<Select
mode="tags"
value={state.selector}
onChange={(v) => update('selector', v)}
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</Form.Item>
<Form.Item label={t('pages.xray.balancer.fallback')}>
<Select
value={state.fallbackTag}
onChange={(v) => update('fallbackTag', v ?? '')}
allowClear
options={fallbackOptions}
/>
</Form.Item>
<FormField name="strategy" label={t('pages.xray.balancer.balancerStrategy')}>
<Select options={STRATEGIES} />
</FormField>
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
<Select
mode="tags"
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</FormField>
<FormField
name="fallbackTag"
label={t('pages.xray.balancer.fallback')}
transform={{ output: (v) => v ?? '' }}
>
<Select allowClear options={fallbackOptions} />
</FormField>
{state.strategy === 'leastLoad' && (
<>
<Form.Item label={t('pages.xray.balancer.expected')}>
<InputNumber
value={settings?.expected}
onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)}
min={0}
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label={t('pages.xray.balancer.maxRtt')}>
<Input
value={settings?.maxRTT ?? ''}
onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)}
placeholder="e.g. 1s"
/>
</Form.Item>
<Form.Item label={t('pages.xray.balancer.tolerance')}>
<InputNumber
value={settings?.tolerance}
onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
min={0}
max={1}
step={0.01}
placeholder="0.01 = 1%"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label={t('pages.xray.balancer.baselines')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateBaselines([...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s"
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
/>
<InputAddon ariaLabel={t('remove')} onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.balancer.costs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
/>
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
/>
<InputNumber
value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
/>
<InputAddon ariaLabel={t('remove')} onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
</>
)}
</Form>
{strategy === 'leastLoad' && (
<>
<FormField
name={['settings', 'expected']}
label={t('pages.xray.balancer.expected')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber
min={0}
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
style={{ width: '100%' }}
/>
</FormField>
<FormField
name={['settings', 'maxRTT']}
label={t('pages.xray.balancer.maxRtt')}
transform={{ input: (v) => v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }}
>
<Input placeholder="e.g. 1s" />
</FormField>
<FormField
name={['settings', 'tolerance']}
label={t('pages.xray.balancer.tolerance')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber min={0} max={1} step={0.01} placeholder="0.01 = 1%" style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.xray.balancer.baselines')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s"
onChange={(e) => methods.setValue('settings.baselines', baselines.map((x, i) => (i === idx ? e.target.value : x)))}
/>
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.baselines', baselines.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.balancer.costs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('settings.costs', [...costs, { regexp: false, match: '', value: 1 }])}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
/>
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
/>
<InputNumber
value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
/>
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.costs', costs.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
</>
)}
</Form>
</FormProvider>
</Modal>
);
}
+105 -118
View File
@@ -2,15 +2,16 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
DnsServerObjectSchema,
type DnsServerObject,
} from '@/schemas/dns';
import { antdRule } from '@/utils/zodForm';
export type DnsServerValue =
| string
@@ -135,17 +136,15 @@ export default function DnsServerModal({
onConfirm,
}: DnsServerModalProps) {
const { t } = useTranslation();
const [form] = Form.useForm<DnsServerForm>();
const methods = useForm<DnsServerForm>({ defaultValues: defaultFormValues() });
const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
useEffect(() => {
if (!open) return;
form.setFieldsValue(valuesFromServer(server));
}, [open, server, form]);
async function submit() {
const values = await form.validateFields();
onConfirm(valuesToWire(values));
}
methods.reset(valuesFromServer(server));
}, [open, server, methods]);
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
@@ -156,124 +155,112 @@ export default function DnsServerModal({
okText={t('confirm')}
cancelText={t('close')}
mask={{ closable: false }}
onOk={submit}
onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
onCancel={onClose}
>
<Form
form={form}
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
initialValues={defaultFormValues()}
>
<Form.Item
label={t('pages.inbounds.address')}
name="address"
rules={[antdRule(shape.address, t)]}
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.port')}
name="port"
rules={[antdRule(shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
<Form.Item label={t('pages.xray.dns.tag')} name="tag">
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.dns.clientIp')} name="clientIP">
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.dns.strategy')} name="queryStrategy">
<Select
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
/>
</Form.Item>
<Form.Item
label={t('pages.xray.dns.timeoutMs')}
name="timeoutMs"
rules={[antdRule(shape.timeoutMs, t)]}
>
<InputNumber min={0} step={500} />
</Form.Item>
<FormField
label={t('pages.inbounds.address')}
name="address"
rules={{ validate: rhfZodValidate(shape.address) }}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.port')}
name="port"
rules={{ validate: rhfZodValidate(shape.port) }}
>
<InputNumber min={1} max={65535} />
</FormField>
<FormField label={t('pages.xray.dns.tag')} name="tag">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
<Select
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
/>
</FormField>
<FormField
label={t('pages.xray.dns.timeoutMs')}
name="timeoutMs"
rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
>
<InputNumber min={0} step={500} />
</FormField>
<Divider style={{ margin: '5px 0' }} />
<Divider style={{ margin: '5px 0' }} />
<Form.List name="domains">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.domains')}>
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item label={t('pages.xray.dns.domains')}>
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('domains', [...domains, ''])} />
{domains.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`domains.${i}`} noStyle>
<Input />
</FormField>
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('domains', domains.filter((__, idx) => idx !== i))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.List name="expectedIPs">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])} />
{expectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`expectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('expectedIPs', expectedIPs.filter((__, idx) => idx !== i))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.List name="unexpectedIPs">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])} />
{unexpectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`unexpectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('unexpectedIPs', unexpectedIPs.filter((__, idx) => idx !== i))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Divider style={{ margin: '5px 0' }} />
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.skipFallback')} name="skipFallback" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.finalQuery')} name="finalQuery" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.disableCache')} name="disableCache" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.serveStale')} name="serveStale" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
<InputNumber min={0} step={60} />
</Form.Item>
</Form>
<FormField label={t('pages.xray.dns.skipFallback')} name="skipFallback" valueProp="checked">
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.disableCache')} name="disableCache" valueProp="checked">
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
<InputNumber min={0} step={60} />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
@@ -11,8 +11,9 @@ import {
Tabs,
message,
} from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport';
import SniffingFields from '@/lib/xray/forms/SniffingFields';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import {
@@ -31,7 +32,6 @@ import {
canEnableTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
import { antdRule } from '@/utils/zodForm';
import {
FLOW_OPTIONS,
@@ -75,10 +75,7 @@ import {
import { RealityForm, TlsForm } from './security';
import './OutboundFormModal.css';
// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
// file so the build stays green section-by-section. The atomic swap at
// the end of the rewrite replaces the legacy file in one commit
// (per Core Decision 7 in the migration spec).
type StreamValue = OutboundFormValues['streamSettings'];
interface OutboundFormModalProps {
open: boolean;
@@ -89,7 +86,6 @@ interface OutboundFormModalProps {
onConfirm: (outbound: Record<string, unknown>) => void;
}
export default function OutboundFormModal({
open,
outbound: outboundProp,
@@ -100,15 +96,36 @@ export default function OutboundFormModal({
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<OutboundFormValues>();
const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
const [activeKey, setActiveKey] = useState('1');
const [jsonText, setJsonText] = useState('');
const [jsonDirty, setJsonDirty] = useState(false);
const [linkInput, setLinkInput] = useState('');
// Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
// hysteria2:// / wireguard://) and replace form state with the result.
// The current tag is preserved when the parsed link doesn't carry one.
const isEdit = outboundProp != null;
const title = isEdit
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ?? '') as string;
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ?? 'none') as string;
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as string | undefined;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
/*
* Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
* hysteria2:// / wireguard://) and replace form state with the result.
* The current tag is preserved when the parsed link doesn't carry one.
*/
function importLink() {
const link = linkInput.trim();
if (!link) return;
@@ -117,11 +134,10 @@ export default function OutboundFormModal({
messageApi.error('Wrong Link!');
return;
}
const currentTag = form.getFieldValue('tag') as string | undefined;
const currentTag = methods.getValues('tag');
if (!parsed.tag && currentTag) parsed.tag = currentTag;
const next = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(next);
methods.reset(next);
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
setJsonDirty(false);
setLinkInput('');
@@ -129,89 +145,76 @@ export default function OutboundFormModal({
switchTab('1');
}
const isEdit = outboundProp != null;
const title = isEdit
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
useEffect(() => {
if (!open) return;
const initial = outboundProp
? rawOutboundToFormValues(outboundProp)
: buildAddModeValues();
form.resetFields();
form.setFieldsValue(initial);
methods.reset(initial);
setActiveKey('1');
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
setJsonDirty(false);
}, [open, outboundProp, form]);
const tag = Form.useWatch('tag', form) ?? '';
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
}, [open, outboundProp, methods]);
useEffect(() => {
if (!streamAllowed) return;
// Wireguard dials its own UDP — only finalmask/sockopt apply, never a
// transport. Don't seed network 'tcp'; clear a leftover one (from a
// protocol switch) so the transmission/security blocks stay hidden.
/*
* Wireguard dials its own UDP — only finalmask/sockopt apply, never a
* transport. Don't seed network 'tcp'; clear a leftover one (from a
* protocol switch) so the transmission/security blocks stay hidden.
*/
if (protocol === 'wireguard') {
if (network) form.setFieldValue('streamSettings', { security: 'none' });
if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
return;
}
if (network) return;
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamAllowed, network, protocol]);
methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
}, [streamAllowed, network, protocol, methods]);
useEffect(() => {
if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return;
const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const slice = hysteriaStreamSlice();
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
form.setFieldValue('streamSettings', slice);
}, [protocol, network, security, form]);
methods.setValue('streamSettings', slice as StreamValue);
}, [protocol, network, security, methods]);
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
useEffect(() => {
if (protocol !== 'wireguard') return;
const sk = (wgSecretKey ?? '').trim();
if (!sk) {
form.setFieldValue(['settings', 'pubKey'], '');
methods.setValue('settings.pubKey', '');
return;
}
try {
const { publicKey } = Wireguard.generateKeypair(sk);
form.setFieldValue(['settings', 'pubKey'], publicKey);
methods.setValue('settings.pubKey', publicKey);
} catch {
form.setFieldValue(['settings', 'pubKey'], '');
methods.setValue('settings.pubKey', '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [protocol, wgSecretKey]);
}, [protocol, wgSecretKey, methods]);
function onValuesChange(changed: Partial<OutboundFormValues>) {
if ('protocol' in changed && changed.protocol) {
const next = rawOutboundToFormValues({ protocol: changed.protocol });
form.setFieldValue('settings', next.settings);
if (changed.protocol === 'hysteria') {
form.setFieldValue('streamSettings', hysteriaStreamSlice());
} else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
useEffect(() => {
/* eslint-disable-next-line react-hooks/incompatible-library */
const sub = methods.watch((_value, { name, type }) => {
if (name !== 'protocol' || type !== 'change') return;
const nextProtocol = methods.getValues('protocol');
const next = rawOutboundToFormValues({ protocol: nextProtocol });
methods.setValue('settings', next.settings);
if (nextProtocol === 'hysteria') {
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
}
}
}
});
return () => sub.unsubscribe();
}, [methods]);
function onSecurityChange(next: string) {
const stream = form.getFieldValue('streamSettings') ?? {};
const cleaned = { ...stream } as Record<string, unknown>;
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const cleaned = { ...stream };
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
@@ -234,23 +237,25 @@ export default function OutboundFormModal({
};
}
cleaned.security = next;
form.setFieldValue('streamSettings', cleaned);
methods.setValue('streamSettings', cleaned as StreamValue);
}
// Network change cascade: swap the per-network sub-key (tcpSettings,
// wsSettings, etc.) so the DU branch matches. Preserve security if
// the new network supports it, otherwise force back to 'none'.
/*
* Network change cascade: swap the per-network sub-key (tcpSettings,
* wsSettings, etc.) so the DU branch matches. Preserve security if
* the new network supports it, otherwise force back to 'none'.
*/
function onNetworkChange(next: string) {
const stream = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next));
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
}
function onXmuxToggle(checked: boolean) {
if (!checked) return;
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
const existing = methods.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 });
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
}
const duplicateTag = useMemo(() => {
@@ -260,9 +265,11 @@ export default function OutboundFormModal({
return (existingTags || []).includes(myTag);
}, [tag, existingTags, isEdit, outboundProp]);
// Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
// any edits into form state. When entering JSON tab, snapshot current
// form values so the user sees the live shape.
/*
* Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
* any edits into form state. When entering JSON tab, snapshot current
* form values so the user sees the live shape.
*/
function applyJsonToForm(): boolean {
if (!jsonDirty) return true;
const raw = jsonText.trim();
@@ -275,8 +282,7 @@ export default function OutboundFormModal({
return false;
}
const next = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(next);
methods.reset(next);
setJsonDirty(false);
return true;
}
@@ -290,7 +296,7 @@ export default function OutboundFormModal({
function onTabChange(key: string) {
if (key === '2') {
const values = form.getFieldsValue(true) as OutboundFormValues;
const values = methods.getValues();
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
setJsonDirty(false);
switchTab(key);
@@ -315,16 +321,11 @@ export default function OutboundFormModal({
return;
}
values = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(values);
methods.reset(values);
setJsonDirty(false);
} else {
try {
await form.validateFields();
} catch {
return;
}
values = form.getFieldsValue(true) as OutboundFormValues;
if (!(await methods.trigger())) return;
values = methods.getValues();
}
const tagValue = (values.tag ?? '').trim();
if (!tagValue) {
@@ -354,218 +355,226 @@ export default function OutboundFormModal({
onCancel={onClose}
destroyOnHidden
>
<Form
form={form}
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
labelWrap
onValuesChange={onValuesChange}
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
items={[
{
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<Form.Item
label={t('protocol')}
name="protocol"
rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
>
<Select options={PROTOCOL_OPTIONS} />
</Form.Item>
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
labelWrap
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
items={[
{
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<FormField
label={t('protocol')}
name="protocol"
rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
>
<Select id="protocol" options={PROTOCOL_OPTIONS} />
</FormField>
<Form.Item
label={t('pages.xray.outbound.tag')}
name="tag"
validateStatus={duplicateTag ? 'warning' : undefined}
help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined}
rules={[
{ required: true, message: t('pages.xray.outboundForm.tagRequired') },
]}
>
<Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.xray.outbound.targetStrategy')}
name="targetStrategy"
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
>
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
</Form.Item>
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{protocol === 'freedom' && <FreedomFields form={form} />}
{protocol === 'vless' && (
<Form.Item shouldUpdate noStyle>
{() => {
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
if (!reverseTag) return null;
<Controller
control={methods.control}
name="tag"
rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
render={({ field, fieldState }) => {
const errorMessage = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: '';
return (
<SniffingFields
name={['settings', 'reverseSniffing']}
form={form}
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
/>
<Form.Item
label={t('pages.xray.outbound.tag')}
required
validateStatus={errorMessage ? 'error' : duplicateTag ? 'warning' : undefined}
help={errorMessage || (duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined)}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
/>
</Form.Item>
);
}}
</Form.Item>
)}
/>
{protocol === 'wireguard' && <WireguardFields form={form} />}
<FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</FormField>
{streamAllowed && network && (
<>
<Form.Item
label={t('transmission')}
name={['streamSettings', 'network']}
>
<Select
value={network}
onChange={onNetworkChange}
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
<FormField
label={t('pages.xray.outbound.targetStrategy')}
name="targetStrategy"
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
>
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
</FormField>
{network === 'tcp' && <RawForm form={form} />}
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{network === 'kcp' && <KcpForm />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{network === 'ws' && <WsForm />}
{protocol === 'freedom' && <FreedomFields />}
{network === 'grpc' && <GrpcForm />}
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />}
{network === 'hysteria' && <HysteriaForm form={form} />}
</>
)}
{tlsFlowAllowed && (
<Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
{protocol === 'vless' && reverseTag && (
<Controller
control={methods.control}
name="settings.reverseSniffing"
render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
/>
)}
/>
</Form.Item>
)}
)}
{/* Vision seed knobs only meaningful for the exact
xtls-rprx-vision flow, on TCP+(tls|reality). The
legacy class gated this on `canEnableVisionSeed()`
— same condition encoded inline here. */}
<Form.Item shouldUpdate noStyle>
{() => {
const flow =
(form.getFieldValue(['settings', 'flow']) ?? '') as string;
if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
return (
<>
<Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
<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>
))}
</Space.Compact>
</Form.Item>
</>
);
}}
</Form.Item>
{protocol === 'wireguard' && <WireguardFields />}
{streamAllowed && network && (
<Form.Item label={t('security')}>
<Radio.Group
value={security}
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
)}
{streamAllowed && network && (
<>
<Form.Item label={t('transmission')}>
<Select
value={network}
onChange={onNetworkChange}
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
{security === 'tls' && tlsAllowed && <TlsForm />}
{network === 'tcp' && <RawForm />}
{security === 'reality' && realityAllowed && <RealityForm />}
{network === 'kcp' && <KcpForm />}
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
<SockoptForm form={form} outboundTags={dialerProxyTags ?? existingTags} />
)}
{network === 'ws' && <WsForm />}
<FinalMaskForm
name={['streamSettings', 'finalmask']}
network={network}
protocol={protocol}
form={form}
/>
{network === 'grpc' && <GrpcForm />}
<MuxForm form={form} protocol={protocol} network={network} />
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink}
/>
<JsonEditor
value={jsonText}
onChange={(next) => {
setJsonText(next);
setJsonDirty(true);
}}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Form>
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
{network === 'hysteria' && <HysteriaForm />}
</>
)}
{tlsFlowAllowed && (
<FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
/>
</FormField>
)}
{/* Vision seed knobs only meaningful for the exact
xtls-rprx-vision flow, on TCP+(tls|reality). */}
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
<>
<FormField label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
<Space.Compact block>
{[0, 1, 2, 3].map((i) => (
<FormField key={i} name={['settings', 'testseed', i]} noStyle>
<InputNumber min={1} style={{ width: '25%' }} />
</FormField>
))}
</Space.Compact>
</Form.Item>
</>
)}
{streamAllowed && network && (
<Form.Item label={t('security')}>
<Radio.Group
value={security}
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
)}
{security === 'tls' && tlsAllowed && <TlsForm />}
{security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
)}
<Controller
control={methods.control}
name="streamSettings.finalmask"
render={({ field }) => (
<FinalMaskField
key={`${protocol}:${network}`}
value={field.value}
onChange={field.onChange}
network={network}
protocol={protocol}
/>
)}
/>
<MuxForm protocol={protocol} network={network} />
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink}
/>
<JsonEditor
value={jsonText}
onChange={(next) => {
setJsonText(next);
setJsonDirty(true);
}}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Form>
</FormProvider>
</Modal>
</>
);
@@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Form, Select } from 'antd';
import { Select } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function BlackholeFields() {
const { t } = useTranslation();
return (
<Form.Item label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<Select
options={[
{ value: '', label: '(empty)' },
@@ -12,6 +14,6 @@ export default function BlackholeFields() {
{ value: 'http', label: 'http' },
]}
/>
</Form.Item>
</FormField>
);
}
@@ -1,15 +1,19 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() {
const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
return (
<>
<Form.Item label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
<FormField label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
<Select
allowClear
placeholder={t('pages.xray.outboundForm.unchanged')}
@@ -18,62 +22,56 @@ export default function DnsFields() {
{ value: 'tcp', label: 'tcp' },
]}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
</FormField>
<FormField label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
</FormField>
<FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
</FormField>
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.xray.outboundForm.rules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
/>
</Form.Item>
<Form.List name={['settings', 'rules']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.xray.outboundForm.rules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
{fields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(index)}
onKeyDown={activateOnKey(() => remove(index))}
/>
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
<Select
options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
/>
</Form.Item>
<Form.Item label="QType" name={[field.name, 'qType']}>
<Input placeholder="1,3,23-24" />
</Form.Item>
<Form.Item label={t('domainName')} name={[field.name, 'domain']}>
<Input placeholder="domain:example.com" />
</Form.Item>
<Form.Item label="RCode" name={[field.name, 'rCode']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</div>
</Form.Item>
<FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'rules', index, 'action']}>
<Select
options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
/>
</FormField>
<FormField label="QType" name={['settings', 'rules', index, 'qType']}>
<Input placeholder="1,3,23-24" />
</FormField>
<FormField label={t('domainName')} name={['settings', 'rules', index, 'domain']}>
<Input placeholder="domain:example.com" />
</FormField>
<FormField label="RCode" name={['settings', 'rules', index, 'rCode']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</FormField>
</div>
))}
</>
);
}
@@ -1,30 +1,56 @@
import { useTranslation } from 'react-i18next';
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
export default function FreedomFields({ form }: { form: FormInstance<OutboundFormValues> }) {
interface FragmentValue {
packets?: string;
length?: string;
interval?: string;
maxSplit?: string;
}
export default function FreedomFields() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const fragment = (useWatch({ control, name: 'settings.fragment' }) ?? {}) as FragmentValue;
const fragmentEnabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
const {
fields: noiseFields,
append: appendNoise,
remove: removeNoise,
} = useFieldArray({ control, name: 'settings.noises' });
const {
fields: finalRuleFields,
append: appendFinalRule,
remove: removeFinalRule,
} = useFieldArray({ control, name: 'settings.finalRules' });
const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as { action?: string }[];
return (
<>
<Form.Item label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
<FormField label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
<Select
options={[
{ value: '', label: `(${t('none')})` },
...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
]}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
</FormField>
<FormField label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
</FormField>
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
</FormField>
<FormField label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
<Select
options={[
{ value: 0, label: `(${t('none')})` },
@@ -32,260 +58,190 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
{ value: 2, label: 'v2' },
]}
/>
</Form.Item>
</FormField>
<Form.Item label={t('pages.xray.outboundForm.fragment')} shouldUpdate noStyle>
{() => {
const fragment = (form.getFieldValue(['settings', 'fragment']) ?? {}) as {
packets?: string;
length?: string;
interval?: string;
maxSplit?: string;
};
const enabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
return (
<>
<Form.Item label="Fragment">
<Switch
checked={enabled}
onChange={(checked) => {
form.setFieldValue(
['settings', 'fragment'],
checked
? {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
}
: { packets: '', length: '', interval: '', maxSplit: '' },
);
}}
/>
</Form.Item>
{enabled && (
<>
<Form.Item
label={t('pages.settings.subFormats.packets')}
name={['settings', 'fragment', 'packets']}
rules={[{
validator: (_rule, value) => {
const str = String(value ?? '').trim();
// xray accepts "tlshello" or any packet-number range (#5075)
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
return Promise.resolve();
}
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
},
}]}
>
<AutoComplete
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
placeholder="tlshello or n-m, e.g. 1-3"
/>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.interval')}
name={['settings', 'fragment', 'interval']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.maxSplit')}
name={['settings', 'fragment', 'maxSplit']}
>
<Input />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.List name={['settings', 'noises']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.settings.subFormats.noises')}>
<Switch
checked={fields.length > 0}
onChange={(checked) => {
if (checked) {
add({
type: 'rand',
packet: '10-20',
delay: '10-16',
applyTo: 'ip',
});
} else {
// remove() with no arg is not supported;
// walk fields in reverse and drop each.
for (let i = fields.length - 1; i >= 0; i--) {
remove(fields[i].name);
}
}
}}
/>
{fields.length > 0 && (
<Button
size="small"
type="primary"
className="ml-8"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
type: 'rand',
packet: '10-20',
delay: '10-16',
applyTo: 'ip',
})
}
/>
)}
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
{fields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
)}
</div>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.type')} name={[field.name, 'type']}>
<Select
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.packet')} name={[field.name, 'packet']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.delayMs')} name={[field.name, 'delay']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.applyTo')} name={[field.name, 'applyTo']}>
<Select
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
</div>
))}
</>
)}
</Form.List>
<Form.List name={['settings', 'finalRules']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
action: 'allow',
network: '',
port: '',
ip: [],
blockDelay: '',
})
<Form.Item label="Fragment">
<Switch
checked={fragmentEnabled}
onChange={(checked) => {
setValue(
'settings.fragment',
checked
? {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
</span>
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
<Select
options={['allow', 'block'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.network')} name={[field.name, 'network']}>
<Select
allowClear
placeholder="(any)"
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.port')} name={[field.name, 'port']}>
<Input placeholder="e.g. 80,443 or 1000-2000" />
</Form.Item>
<Form.Item label="IP / CIDR / geoip" name={[field.name, 'ip']}>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private"
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const ruleAction = form.getFieldValue([
'settings',
'finalRules',
field.name,
'action',
]);
if (ruleAction !== 'block') return null;
return (
<Form.Item
label={t('pages.xray.outboundForm.blockDelay')}
name={[field.name, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</Form.Item>
);
}}
</Form.Item>
</div>
))}
</>
: { packets: '', length: '', interval: '', maxSplit: '' },
);
}}
/>
</Form.Item>
{fragmentEnabled && (
<>
<FormField
label={t('pages.settings.subFormats.packets')}
name={['settings', 'fragment', 'packets']}
rules={{
validate: (value) => {
const str = String(value ?? '').trim();
/* xray accepts "tlshello" or any packet-number range (#5075) */
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
return 'Use "tlshello" or a packet range like 1-3';
},
}}
>
<AutoComplete
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
placeholder="tlshello or n-m, e.g. 1-3"
/>
</FormField>
<FormField label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
<Input />
</FormField>
<FormField label={t('pages.settings.subFormats.interval')} name={['settings', 'fragment', 'interval']}>
<Input />
</FormField>
<FormField label={t('pages.settings.subFormats.maxSplit')} name={['settings', 'fragment', 'maxSplit']}>
<Input />
</FormField>
</>
)}
<Form.Item label={t('pages.settings.subFormats.noises')}>
<Switch
checked={noiseFields.length > 0}
onChange={(checked) => {
if (checked) {
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
} else {
removeNoise();
}
}}
/>
{noiseFields.length > 0 && (
<Button
size="small"
type="primary"
className="ml-8"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })}
/>
)}
</Form.List>
</Form.Item>
{noiseFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
{noiseFields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeNoise(index)}
onKeyDown={activateOnKey(() => removeNoise(index))}
/>
)}
</div>
</Form.Item>
<FormField label={t('pages.settings.subFormats.type')} name={['settings', 'noises', index, 'type']}>
<Select
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField label={t('pages.settings.subFormats.packet')} name={['settings', 'noises', index, 'packet']}>
<Input />
</FormField>
<FormField label={t('pages.settings.subFormats.delayMs')} name={['settings', 'noises', index, 'delay']}>
<Input />
</FormField>
<FormField label={t('pages.settings.subFormats.applyTo')} name={['settings', 'noises', index, 'applyTo']}>
<Select
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
</div>
))}
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
</span>
</Form.Item>
{finalRuleFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeFinalRule(index)}
onKeyDown={activateOnKey(() => removeFinalRule(index))}
/>
</div>
</Form.Item>
<FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'finalRules', index, 'action']}>
<Select
options={['allow', 'block'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField label={t('pages.inbounds.network')} name={['settings', 'finalRules', index, 'network']}>
<Select
allowClear
placeholder="(any)"
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField label={t('pages.inbounds.port')} name={['settings', 'finalRules', index, 'port']}>
<Input placeholder="e.g. 80,443 or 1000-2000" />
</FormField>
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private"
/>
</FormField>
{finalRulesValues[index]?.action === 'block' && (
<FormField
label={t('pages.xray.outboundForm.blockDelay')}
name={['settings', 'finalRules', index, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</FormField>
)}
</div>
))}
</>
);
}
@@ -1,21 +1,22 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpFields() {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('username')} name={['settings', 'user']}>
<FormField label={t('username')} name={['settings', 'user']}>
<Input />
</Form.Item>
<Form.Item label={t('password')} name={['settings', 'pass']}>
</FormField>
<FormField label={t('password')} name={['settings', 'pass']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
</FormField>
<FormField label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
<HeaderMapEditor mode="v1" />
</Form.Item>
</FormField>
</>
);
}
@@ -1,22 +1,30 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import SniffingFields from '@/lib/xray/forms/SniffingFields';
import { FormField } from '@/components/form/rhf';
import { SniffingField } from '@/lib/xray/forms/fields';
export default function LoopbackFields() {
const { t } = useTranslation();
const form = Form.useFormInstance();
const { control } = useFormContext();
return (
<>
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
<FormField label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
</Form.Item>
</FormField>
<SniffingFields
name={['settings', 'sniffing']}
form={form}
enableLabel={t('pages.inbounds.sniffingTab')}
<Controller
control={control}
name="settings.sniffing"
render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('pages.inbounds.sniffingTab')}
/>
)}
/>
</>
);
@@ -1,24 +1,28 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber } from 'antd';
import { Input, InputNumber } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function ServerTarget() {
const { t } = useTranslation();
return (
<>
<Form.Item
<FormField
label={t('pages.inbounds.address')}
name={['settings', 'address']}
rules={[{ required: true, message: t('pages.xray.outboundForm.addressRequired') }]}
required
rules={{ required: 'pages.xray.outboundForm.addressRequired' }}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.port')}
name={['settings', 'port']}
rules={[{ required: true, message: t('pages.xray.outboundForm.portRequired') }]}
required
rules={{ required: 'pages.xray.outboundForm.portRequired' }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</FormField>
</>
);
}
@@ -1,9 +1,9 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { Input, InputNumber, Select, Switch } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { ShadowsocksOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
import { antdRule } from '@/utils/zodForm';
import { SS_METHOD_OPTIONS } from '../outbound-form-constants';
@@ -11,30 +11,30 @@ export default function ShadowsocksFields() {
const { t } = useTranslation();
return (
<>
<Form.Item
<FormField
label={t('password')}
name={['settings', 'password']}
rules={[antdRule(ShadowsocksOutboundFormSettingsSchema.shape.password, t)]}
rules={{ validate: rhfZodValidate(ShadowsocksOutboundFormSettingsSchema.shape.password) }}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('encryption')}
name={['settings', 'method']}
rules={[antdRule(SSMethodSchema, t)]}
rules={{ validate: rhfZodValidate(SSMethodSchema) }}
>
<Select options={SS_METHOD_OPTIONS} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.xray.outboundForm.udpOverTcp')}
name={['settings', 'uot']}
valuePropName="checked"
valueProp="checked"
>
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
</FormField>
<FormField label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
<InputNumber min={1} max={2} />
</Form.Item>
</FormField>
</>
);
}
@@ -1,16 +1,18 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function SocksFields() {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('username')} name={['settings', 'user']}>
<FormField label={t('username')} name={['settings', 'user']}>
<Input />
</Form.Item>
<Form.Item label={t('password')} name={['settings', 'pass']}>
</FormField>
<FormField label={t('password')} name={['settings', 'pass']}>
<Input />
</Form.Item>
</FormField>
</>
);
}
@@ -1,18 +1,18 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { TrojanOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
export default function TrojanFields() {
const { t } = useTranslation();
return (
<Form.Item
<FormField
label={t('password')}
name={['settings', 'password']}
rules={[antdRule(TrojanOutboundFormSettingsSchema.shape.password, t)]}
rules={{ validate: rhfZodValidate(TrojanOutboundFormSettingsSchema.shape.password) }}
>
<Input />
</Form.Item>
</FormField>
);
}
@@ -1,33 +1,33 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
VlessOutboundFormSettingsSchema,
VmessOutboundFormSettingsSchema,
} from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
export default function VlessFields() {
const { t } = useTranslation();
return (
<>
<Form.Item
<FormField
label="ID"
name={['settings', 'id']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
>
<Input placeholder="UUID" />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('encryption')}
name={['settings', 'encryption']}
rules={[antdRule(VlessOutboundFormSettingsSchema.shape.encryption, t)]}
rules={{ validate: rhfZodValidate(VlessOutboundFormSettingsSchema.shape.encryption) }}
>
<Input />
</Form.Item>
<Form.Item label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
</FormField>
<FormField label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
<Input placeholder={t('pages.xray.outboundForm.optional')} />
</Form.Item>
</FormField>
</>
);
}
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Select } from 'antd';
import { Input, Select } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { VmessOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
import { SECURITY_OPTIONS } from '../outbound-form-constants';
@@ -10,20 +10,20 @@ export default function VmessFields() {
const { t } = useTranslation();
return (
<>
<Form.Item
<FormField
label="ID"
name={['settings', 'id']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
>
<Input placeholder="UUID" />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('security')}
name={['settings', 'security']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.security, t)]}
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.security) }}
>
<Select options={SECURITY_OPTIONS} />
</Form.Item>
</FormField>
</>
);
}
@@ -1,147 +1,149 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import { WireguardDomainStrategy } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
export default function WireguardFields({ form }: { form: FormInstance<OutboundFormValues> }) {
function AllowedIPsList({ peerIndex }: { peerIndex: number }) {
const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
name: `settings.peers.${peerIndex}.allowedIPs`,
});
return (
<>
<Form.Item label={t('pages.inbounds.address')} name={['settings', 'address']}>
{fields.map((field, ipIdx) => (
<Space.Compact key={field.id} block style={{ marginBottom: 4 }}>
<FormField noStyle name={['settings', 'peers', peerIndex, 'allowedIPs', ipIdx]}>
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</FormField>
{fields.length > 1 && (
<InputAddon ariaLabel={t('remove')} onClick={() => remove(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => append('')}
/>
</>
);
}
export default function WireguardFields() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const {
fields: peerFields,
append: appendPeer,
remove: removePeer,
} = useFieldArray({ control, name: 'settings.peers' });
return (
<>
<FormField label={t('pages.inbounds.address')} name={['settings', 'address']}>
<Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
</Form.Item>
</FormField>
<Form.Item label={t('pages.inbounds.privatekey')}>
<Space.Compact block>
<Form.Item name={['settings', 'secretKey']} noStyle>
<FormField name={['settings', 'secretKey']} noStyle>
<Input aria-label={t('pages.inbounds.privatekey')} style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
</FormField>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => {
const pair = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
setValue('settings.secretKey', pair.privateKey);
setValue('settings.pubKey', pair.publicKey);
}}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
<FormField label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
<Input disabled />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
</FormField>
<FormField label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
<Select
options={[
{ value: '', label: `(${t('none')})` },
...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
]}
/>
</Form.Item>
<Form.Item label="MTU" name={['settings', 'mtu']}>
</FormField>
<FormField label="MTU" name={['settings', 'mtu']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.info.noKernelTun')}
name={['settings', 'noKernelTun']}
valuePropName="checked"
valueProp="checked"
>
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
</FormField>
<FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
<Input placeholder="comma-separated bytes, e.g. 1,2,3" />
</FormField>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendPeer({
publicKey: '',
psk: '',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '',
keepAlive: 0,
})
}
/>
</Form.Item>
<Form.List name={['settings', 'peers']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
publicKey: '',
psk: '',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '',
keepAlive: 0,
})
}
/>
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
{fields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
)}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.endpoint')} name={[field.name, 'endpoint']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.publicKey')}
name={[field.name, 'publicKey']}
>
<Input />
</Form.Item>
<Form.Item label="PSK" name={[field.name, 'psk']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
<Form.List name={[field.name, 'allowedIPs']}>
{(ipFields, { add: addIp, remove: removeIp }) => (
<>
{ipFields.map((ipField, ipIdx) => (
<Space.Compact
key={ipField.key}
block
style={{ marginBottom: 4 }}
>
<Form.Item noStyle name={ipField.name}>
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</Form.Item>
{ipFields.length > 1 && (
<InputAddon ariaLabel={t('remove')} onClick={() => removeIp(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addIp('')}
/>
</>
)}
</Form.List>
</Form.Item>
<Form.Item label={t('pages.inbounds.info.keepAlive')} name={[field.name, 'keepAlive']}>
<InputNumber min={0} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
{peerFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
{peerFields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removePeer(index)}
onKeyDown={activateOnKey(() => removePeer(index))}
/>
)}
</div>
</Form.Item>
<FormField label={t('pages.xray.wireguard.endpoint')} name={['settings', 'peers', index, 'endpoint']}>
<Input />
</FormField>
<FormField label={t('pages.inbounds.publicKey')} name={['settings', 'peers', index, 'publicKey']}>
<Input />
</FormField>
<FormField label="PSK" name={['settings', 'peers', index, 'psk']}>
<Input />
</FormField>
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
<AllowedIPsList peerIndex={index} />
</Form.Item>
<FormField label={t('pages.inbounds.info.keepAlive')} name={['settings', 'peers', index, 'keepAlive']}>
<InputNumber min={0} />
</FormField>
</div>
))}
</>
);
}
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Select } from 'antd';
import { Input, Select } from 'antd';
import { FormField } from '@/components/form/rhf';
import { UTLS_OPTIONS } from '../outbound-form-constants';
@@ -7,42 +9,42 @@ export default function RealityForm() {
const { t } = useTranslation();
return (
<>
<Form.Item
<FormField
label="SNI"
name={['streamSettings', 'realitySettings', 'serverName']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label="uTLS"
name={['streamSettings', 'realitySettings', 'fingerprint']}
>
<Select options={UTLS_OPTIONS} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.xray.outboundForm.shortId')}
name={['streamSettings', 'realitySettings', 'shortId']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.spiderX')}
name={['streamSettings', 'realitySettings', 'spiderX']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.publicKey')}
name={['streamSettings', 'realitySettings', 'publicKey']}
>
<Input.TextArea autoSize={{ minRows: 2 }} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.mldsa65Verify')}
name={['streamSettings', 'realitySettings', 'mldsa65Verify']}
>
<Input.TextArea autoSize={{ minRows: 2 }} />
</Form.Item>
</FormField>
</>
);
}
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Select } from 'antd';
import { Input, Select } from 'antd';
import { FormField } from '@/components/form/rhf';
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
@@ -7,13 +9,13 @@ export default function TlsForm() {
const { t } = useTranslation();
return (
<>
<Form.Item
<FormField
label="SNI"
name={['streamSettings', 'tlsSettings', 'serverName']}
>
<Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label="uTLS"
name={['streamSettings', 'tlsSettings', 'fingerprint']}
>
@@ -22,31 +24,31 @@ export default function TlsForm() {
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
/>
</Form.Item>
<Form.Item
</FormField>
<FormField
label="ALPN"
name={['streamSettings', 'tlsSettings', 'alpn']}
>
<Select mode="multiple" options={ALPN_OPTIONS} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label="ECH"
name={['streamSettings', 'tlsSettings', 'echConfigList']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.xray.outboundForm.verifyPeerName')}
name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']}
>
<Input placeholder="cloudflare-dns.com" />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.xray.outboundForm.pinnedSha256')}
name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']}
>
<Input placeholder="base64 SHA256" />
</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
label={t('pages.inbounds.form.serviceName')}
name={['streamSettings', 'grpcSettings', 'serviceName']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.authority')}
name={['streamSettings', 'grpcSettings', 'authority']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.multiMode')}
name={['streamSettings', 'grpcSettings', 'multiMode']}
valuePropName="checked"
valueProp="checked"
>
<Switch />
</Form.Item>
</FormField>
</>
);
}
@@ -1,30 +1,31 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpUpgradeForm() {
const { t } = useTranslation();
return (
<>
<Form.Item
<FormField
label={t('host')}
name={['streamSettings', 'httpupgradeSettings', 'host']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('path')}
name={['streamSettings', 'httpupgradeSettings', '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,134 +1,108 @@
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'];
const MASQ = ['streamSettings', 'hysteriaSettings', 'masquerade'];
const MASQ_DOT = 'streamSettings.hysteriaSettings.masquerade';
export default function HysteriaForm({ form }: { form: FormInstance }) {
export default function HysteriaForm() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const masquerade = useWatch({ control, name: MASQ_DOT }) as { type?: string } | undefined;
return (
<>
<Form.Item
<FormField
label={t('pages.inbounds.form.version')}
name={['streamSettings', 'hysteriaSettings', 'version']}
>
<InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.xray.outboundForm.authPassword')}
name={['streamSettings', 'hysteriaSettings', 'auth']}
>
<Input />
</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={!!masquerade}
onChange={(checked) =>
setValue(
MASQ_DOT,
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 (
{masquerade && (
<>
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ, '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>
{masquerade.type === 'proxy' && (
<>
<Form.Item
label={t('pages.inbounds.form.type')}
name={[...MASQ_PATH, 'type']}
<FormField label={t('pages.inbounds.form.upstreamUrl')} name={[...MASQ, 'url']}>
<Input placeholder="https://www.example.com" />
</FormField>
<FormField
label={t('pages.inbounds.form.rewriteHost')}
name={[...MASQ, 'rewriteHost']}
valueProp="checked"
>
<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>
</>
)}
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.skipTlsVerify')}
name={[...MASQ, 'insecure']}
valueProp="checked"
>
<Switch />
</FormField>
</>
);
}}
</Form.Item>
)}
{masquerade.type === 'file' && (
<FormField label={t('pages.inbounds.form.directory')} name={[...MASQ, 'dir']}>
<Input placeholder="/var/www/html" />
</FormField>
)}
{masquerade.type === 'string' && (
<>
<FormField label={t('pages.inbounds.form.statusCode')} name={[...MASQ, 'statusCode']}>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</FormField>
<FormField label={t('pages.inbounds.form.body')} name={[...MASQ, 'content']}>
<Input.TextArea autoSize={{ minRows: 3 }} />
</FormField>
<FormField label={t('pages.inbounds.form.headers')} name={[...MASQ, 'headers']}>
<HeaderMapEditor mode="v1" />
</FormField>
</>
)}
</>
)}
</>
);
}
@@ -1,40 +1,42 @@
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 label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
<FormField label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
</FormField>
<FormField label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.uplinkMbps')}
name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.downlinkMbps')}
name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.cwndMultiplier')}
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.maxSendingWindow')}
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
>
<InputNumber min={0} />
</Form.Item>
</FormField>
</>
);
}
@@ -1,63 +1,58 @@
import { useTranslation } from 'react-i18next';
import { Form, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { InputNumber, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { FormField } from '@/components/form/rhf';
import { isMuxAllowed } from '../outbound-form-helpers';
interface MuxFormProps {
form: FormInstance<OutboundFormValues>;
protocol: string;
network: string;
}
export default function MuxForm({ form, protocol, network }: MuxFormProps) {
export default function MuxForm({ protocol, network }: MuxFormProps) {
const { t } = useTranslation();
const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
const { control } = useFormContext();
const flow = (useWatch({ control, name: 'settings.flow' }) ?? '') as string;
const muxEnabled = !!useWatch({ control, name: 'mux.enabled' });
if (!isMuxAllowed(protocol, flow, network)) return null;
return (
<Form.Item shouldUpdate noStyle>
{() => {
const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
return (
<>
<Form.Item
label={t('pages.settings.mux')}
name={['mux', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
{muxEnabled && (
<>
<Form.Item
label={t('pages.settings.subFormats.concurrency')}
name={['mux', 'concurrency']}
>
<InputNumber min={-1} max={1024} />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.xudpConcurrency')}
name={['mux', 'xudpConcurrency']}
>
<InputNumber min={-1} max={1024} />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.xudpUdp443')}
name={['mux', 'xudpProxyUDP443']}
>
<Select
options={['reject', 'allow', 'skip'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<>
<FormField
label={t('pages.settings.mux')}
name={['mux', 'enabled']}
valueProp="checked"
>
<Switch />
</FormField>
{muxEnabled && (
<>
<FormField
label={t('pages.settings.subFormats.concurrency')}
name={['mux', 'concurrency']}
>
<InputNumber min={-1} max={1024} />
</FormField>
<FormField
label={t('pages.settings.subFormats.xudpConcurrency')}
name={['mux', 'xudpConcurrency']}
>
<InputNumber min={-1} max={1024} />
</FormField>
<FormField
label={t('pages.settings.subFormats.xudpUdp443')}
name={['mux', 'xudpProxyUDP443']}
>
<Select
options={['reject', 'allow', 'skip'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
</>
)}
</>
);
}
@@ -1,136 +1,109 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Switch, type FormInstance } from 'antd';
import { Form, Input, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { FormField } from '@/components/form/rhf';
export default function RawForm({ form }: { form: FormInstance<OutboundFormValues> }) {
export default function RawForm() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const type = (useWatch({
control,
name: 'streamSettings.tcpSettings.header.type',
}) ?? 'none') as string;
return (
<Form.Item shouldUpdate noStyle>
{() => {
const type =
form.getFieldValue([
'streamSettings',
'tcpSettings',
'header',
'type',
]) ?? 'none';
return (
<>
<Form.Item label={`HTTP ${t('camouflage')}`}>
<Switch
checked={type === 'http'}
onChange={(checked) =>
form.setFieldValue(
['streamSettings', 'tcpSettings', 'header'],
checked
? {
type: 'http',
request: {
version: '1.1',
method: 'GET',
path: ['/'],
headers: {},
},
response: {
version: '1.1',
status: '200',
reason: 'OK',
headers: {},
},
}
: { type: 'none' },
)
<>
<Form.Item label={`HTTP ${t('camouflage')}`}>
<Switch
checked={type === 'http'}
onChange={(checked) =>
setValue(
'streamSettings.tcpSettings.header',
checked
? {
type: 'http',
request: {
version: '1.1',
method: 'GET',
path: ['/'],
headers: {},
},
response: {
version: '1.1',
status: '200',
reason: 'OK',
headers: {},
},
}
/>
</Form.Item>
{type === 'http' && (
<>
<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>
: { type: 'none' },
)
}
/>
</Form.Item>
{type === '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>
<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>
<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,225 +1,209 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { Form, Input, InputNumber, 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 { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
export default function SockoptForm({
form,
outboundTags = [],
}: {
form: FormInstance<OutboundFormValues>;
outboundTags?: string[];
}) {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const sockopt = useWatch({ control, name: 'streamSettings.sockopt' });
const hasSockopt = !!sockopt;
const dialerProxy = (useWatch({ control, name: 'streamSettings.sockopt.dialerProxy' }) ?? '') as string;
const happyEyeballs = useWatch({ control, name: 'streamSettings.sockopt.happyEyeballs' });
const hasHe = happyEyeballs != null;
const dialerProxyOptions = Array.from(
new Set([...outboundTags, dialerProxy].filter(Boolean)),
).map((tg) => ({ value: tg, label: tg }));
return (
<Form.Item shouldUpdate noStyle>
{() => {
const hasSockopt = !!form.getFieldValue([
'streamSettings',
'sockopt',
]);
const dialerProxy = (form.getFieldValue([
'streamSettings',
'sockopt',
'dialerProxy',
]) ?? '') as string;
const dialerProxyOptions = Array.from(
new Set([...outboundTags, dialerProxy].filter(Boolean)),
).map((tg) => ({ value: tg, label: tg }));
return (
<>
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
<Switch
checked={hasSockopt}
onChange={(checked) => {
form.setFieldValue(
['streamSettings', 'sockopt'],
checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasSockopt && (
<>
<Form.Item
label={t('pages.inbounds.form.dialerProxy')}
name={['streamSettings', 'sockopt', 'dialerProxy']}
tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
>
<Select
allowClear
showSearch
placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
options={dialerProxyOptions}
/>
</Form.Item>
<Form.Item
label={t('pages.xray.wireguard.domainStrategy')}
name={['streamSettings', 'sockopt', 'domainStrategy']}
>
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.addressPortStrategy')}
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
>
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.keepAliveInterval')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpFastOpen')}
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.multipathTcp')}
name={['streamSettings', 'sockopt', 'tcpMptcp']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.penetrate')}
name={['streamSettings', 'sockopt', 'penetrate']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.markFwmark')}
name={['streamSettings', 'sockopt', 'mark']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.interface')}
name={['streamSettings', 'sockopt', 'interface']}
>
<Input />
</Form.Item>
<Form.Item
label="TProxy"
name={['streamSettings', 'sockopt', 'tproxy']}
>
<Select
options={[
{ value: 'off', label: 'off' },
{ value: 'redirect', label: 'redirect' },
{ value: 'tproxy', label: 'tproxy' },
]}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpCongestion')}
name={['streamSettings', 'sockopt', 'tcpcongestion']}
>
<Select
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpMaxSeg')}
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpWindowClamp')}
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const he = form.getFieldValue([
'streamSettings', 'sockopt', 'happyEyeballs',
]);
const hasHe = he != null;
return (
<>
<Form.Item label="Happy Eyeballs">
<Switch
checked={hasHe}
onChange={(v) => {
form.setFieldValue(
['streamSettings', 'sockopt', 'happyEyeballs'],
v ? HappyEyeballsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasHe && (
<>
<Form.Item
label={t('pages.inbounds.form.tryDelayMs')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
>
<InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.prioritizeIPv6')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.interleave')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.maxConcurrentTry')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
>
<InputNumber min={0} />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<CustomSockoptList />
</>
<>
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
<Switch
checked={hasSockopt}
onChange={(checked) => {
setValue(
'streamSettings.sockopt',
checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasSockopt && (
<>
<FormField
label={t('pages.inbounds.form.dialerProxy')}
name={['streamSettings', 'sockopt', 'dialerProxy']}
tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
>
<Select
allowClear
showSearch
placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
options={dialerProxyOptions}
/>
</FormField>
<FormField
label={t('pages.xray.wireguard.domainStrategy')}
name={['streamSettings', 'sockopt', 'domainStrategy']}
>
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.inbounds.form.addressPortStrategy')}
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
>
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.keepAliveInterval')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.tcpFastOpen')}
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.multipathTcp')}
name={['streamSettings', 'sockopt', 'tcpMptcp']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.penetrate')}
name={['streamSettings', 'sockopt', 'penetrate']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
label={t('pages.xray.outboundForm.markFwmark')}
name={['streamSettings', 'sockopt', 'mark']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.interface')}
name={['streamSettings', 'sockopt', 'interface']}
>
<Input />
</FormField>
<FormField
label="TProxy"
name={['streamSettings', 'sockopt', 'tproxy']}
>
<Select
options={[
{ value: 'off', label: 'off' },
{ value: 'redirect', label: 'redirect' },
{ value: 'tproxy', label: 'tproxy' },
]}
/>
</FormField>
<FormField
label={t('pages.inbounds.form.tcpCongestion')}
name={['streamSettings', 'sockopt', 'tcpcongestion']}
>
<Select
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.tcpMaxSeg')}
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.tcpWindowClamp')}
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
>
<InputNumber min={0} />
</FormField>
<Form.Item label="Happy Eyeballs">
<Switch
checked={hasHe}
onChange={(v) => {
setValue(
'streamSettings.sockopt.happyEyeballs',
v ? HappyEyeballsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasHe && (
<>
<FormField
label={t('pages.inbounds.form.tryDelayMs')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
>
<InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
</FormField>
<FormField
label={t('pages.inbounds.form.prioritizeIPv6')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.interleave')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
>
<InputNumber min={1} />
</FormField>
<FormField
label={t('pages.inbounds.form.maxConcurrentTry')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
>
<InputNumber min={0} />
</FormField>
</>
)}
<Controller
control={control}
name="streamSettings.sockopt.customSockopt"
render={({ field }) => (
<SockoptCustomField value={field.value} onChange={field.onChange} />
)}
</>
);
}}
</Form.Item>
/>
</>
)}
</>
);
}
@@ -1,30 +1,31 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber } from 'antd';
import { Input, InputNumber } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function WsForm() {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
<FormField label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
<Input />
</Form.Item>
<Form.Item label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
</FormField>
<FormField label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.heartbeatPeriod')}
name={['streamSettings', 'wsSettings', '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,151 +1,141 @@
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 { FormField } from '@/components/form/rhf';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
import { MODE_OPTIONS } from '../outbound-form-constants';
interface XhttpFormProps {
form: FormInstance<OutboundFormValues>;
onXmuxToggle: (checked: boolean) => void;
}
export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
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;
}
};
}
const XH = 'streamSettings.xhttpSettings';
export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
const { t } = useTranslation();
const { control } = useFormContext();
const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
const table = useWatch({ control, name: `${XH}.sessionIDTable` });
const seqPlacement = useWatch({ control, name: `${XH}.seqPlacement` }) as string | undefined;
const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
return (
<>
<Form.Item
label={t('host')}
name={['streamSettings', 'xhttpSettings', 'host']}
>
<FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
<Input />
</Form.Item>
<Form.Item
label={t('path')}
name={['streamSettings', 'xhttpSettings', 'path']}
>
</FormField>
<FormField label={t('path')} name={['streamSettings', 'xhttpSettings', 'path']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.info.mode')}
name={['streamSettings', 'xhttpSettings', 'mode']}
>
</FormField>
<FormField label={t('pages.inbounds.info.mode')} name={['streamSettings', 'xhttpSettings', 'mode']}>
<Select options={MODE_OPTIONS} />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.paddingBytes')}
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
>
<Input />
</Form.Item>
<Form.Item
</FormField>
<FormField
label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'xhttpSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</FormField>
{/* Padding obfs sub-section: gated by a Switch.
When on, four extra knobs (key/header/placement/
method) tune how Xray injects random padding to
disguise the post body shape. */}
<Form.Item
<FormField
label={t('pages.inbounds.form.paddingObfsMode')}
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
valuePropName="checked"
valueProp="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const obfs = !!form.getFieldValue([
'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
]);
if (!obfs) return null;
return (
<>
<Form.Item
label={t('pages.inbounds.form.paddingKey')}
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
>
<Input placeholder="x_padding" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.paddingHeader')}
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
>
<Input placeholder="X-Padding" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.paddingPlacement')}
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.paddingMethod')}
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
>
<Select
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
{ value: 'tokenish', label: 'tokenish' },
]}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</FormField>
{obfs && (
<>
<FormField
label={t('pages.inbounds.form.paddingKey')}
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
>
<Input placeholder="x_padding" />
</FormField>
<FormField
label={t('pages.inbounds.form.paddingHeader')}
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
>
<Input placeholder="X-Padding" />
</FormField>
<FormField
label={t('pages.inbounds.form.paddingPlacement')}
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
<FormField
label={t('pages.inbounds.form.paddingMethod')}
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
>
<Select
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
{ value: 'tokenish', label: 'tokenish' },
]}
/>
</FormField>
</>
)}
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev?.streamSettings?.xhttpSettings?.mode !==
curr?.streamSettings?.xhttpSettings?.mode
}
<FormField
label={t('pages.inbounds.form.uplinkHttpMethod')}
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
>
{() => {
const mode = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'mode',
]);
return (
<Form.Item
label={t('pages.inbounds.form.uplinkHttpMethod')}
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
>
<Select
placeholder="Default (POST)"
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
]}
/>
</Form.Item>
);
}}
</Form.Item>
<Select
placeholder="Default (POST)"
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
]}
/>
</FormField>
{/* Session + sequence + uplinkData placements:
three orthogonal slots Xray uses to thread
request metadata through the transport
(path / header / cookie / query). Key field
only matters when placement is not 'path'. */}
<Form.Item
<FormField
label={t('pages.inbounds.form.sessionPlacement')}
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
>
@@ -159,54 +149,38 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const placement = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'sessionIDPlacement',
]);
if (!placement || placement === 'path') return null;
return (
<Form.Item
label={t('pages.inbounds.form.sessionKey')}
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
>
<Input placeholder="x_session" />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
</FormField>
{sessionPlacement && sessionPlacement !== 'path' && (
<FormField
label={t('pages.inbounds.form.sessionKey')}
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
>
<Input placeholder="x_session" />
</FormField>
)}
<FormField
label={t('pages.inbounds.form.sessionIDTable')}
tooltip={t('pages.inbounds.form.sessionIDTableHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
rules={[{ validator: validateSessionIDTable }]}
rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
>
<AutoComplete
allowClear
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
placeholder="Base62"
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const table = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'sessionIDTable',
]);
if (!table) return null;
return (
<Form.Item
label={t('pages.inbounds.form.sessionIDLength')}
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
rules={[{ validator: validateSessionIDLength }]}
>
<Input placeholder="8-16" />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
</FormField>
{!!table && (
<FormField
label={t('pages.inbounds.form.sessionIDLength')}
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
>
<Input placeholder="8-16" />
</FormField>
)}
<FormField
label={t('pages.inbounds.form.sequencePlacement')}
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
>
@@ -220,168 +194,130 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const placement = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'seqPlacement',
]);
if (!placement || placement === 'path') return null;
return (
<Form.Item
label={t('pages.inbounds.form.sequenceKey')}
name={['streamSettings', 'xhttpSettings', 'seqKey']}
>
<Input placeholder="x_seq" />
</Form.Item>
);
}}
</Form.Item>
</FormField>
{seqPlacement && seqPlacement !== 'path' && (
<FormField
label={t('pages.inbounds.form.sequenceKey')}
name={['streamSettings', 'xhttpSettings', 'seqKey']}
>
<Input placeholder="x_seq" />
</FormField>
)}
{/* Mode-conditional sub-sections. */}
<Form.Item shouldUpdate noStyle>
{() => {
const mode = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'mode',
]);
if (mode !== 'packet-up' && mode !== 'auto') return null;
return (
{(mode === 'packet-up' || mode === 'auto') && (
<>
<FormField
label={t('pages.xray.outboundForm.minUploadInterval')}
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
>
<Input placeholder="e.g. 50-150" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
>
<Input placeholder="1000000" />
</FormField>
<FormField
label={t('pages.inbounds.form.uplinkDataPlacement')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
{uplinkDataPlacement && uplinkDataPlacement !== 'body' && (
<>
<Form.Item
label={t('pages.xray.outboundForm.minUploadInterval')}
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
<FormField
label={t('pages.inbounds.form.uplinkDataKey')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
>
<Input placeholder="e.g. 50-150" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
<Input placeholder="x_data" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.uplinkChunkSize')}
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
>
<Input placeholder="1000000" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.uplinkDataPlacement')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
<InputNumber
min={0}
placeholder="0 (unlimited)"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const place = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
]);
if (!place || place === 'body') return null;
return (
<>
<Form.Item
label={t('pages.inbounds.form.uplinkDataKey')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
>
<Input placeholder="x_data" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.uplinkChunkSize')}
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
>
<InputNumber
min={0}
placeholder="0 (unlimited)"
style={{ width: '100%' }}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</FormField>
</>
);
}}
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const mode = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'mode',
]);
if (mode !== 'stream-up' && mode !== 'stream-one') return null;
return (
<Form.Item
label={t('pages.xray.outboundForm.noGrpcHeader')}
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
valuePropName="checked"
>
<Switch />
</Form.Item>
);
}}
</Form.Item>
)}
</>
)}
{(mode === 'stream-up' || mode === 'stream-one') && (
<FormField
label={t('pages.xray.outboundForm.noGrpcHeader')}
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
valueProp="checked"
>
<Switch />
</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>
</>
)}
</>
);
}
+55 -50
View File
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil } from '@/utils';
import { FormField } from '@/components/form/rhf';
import './NordModal.css';
interface NordModalProps {
@@ -44,6 +46,22 @@ interface NordServer {
cityName?: string;
}
interface NordFormValues {
token: string;
manualKey: string;
countryId: number | null;
cityId: number | null;
serverId: number | null;
}
const EMPTY: NordFormValues = {
token: '',
manualKey: '',
countryId: null,
cityId: null,
serverId: null,
};
function loadColor(load: number): string {
if (load < 30) return 'green';
if (load < 70) return 'orange';
@@ -63,14 +81,12 @@ export default function NordModal({
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null);
const [token, setToken] = useState('');
const [manualKey, setManualKey] = useState('');
const [countries, setCountries] = useState<Country[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [servers, setServers] = useState<NordServer[]>([]);
const [countryId, setCountryId] = useState<number | null>(null);
const [cityId, setCityId] = useState<number | null>(null);
const [serverId, setServerId] = useState<number | null>(null);
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
const cityId = useWatch({ control: methods.control, name: 'cityId' });
const serverId = useWatch({ control: methods.control, name: 'serverId' });
const nordOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
@@ -84,8 +100,8 @@ export default function NordModal({
}, [cityId, servers]);
useEffect(() => {
setServerId(filteredServers.length > 0 ? filteredServers[0].id : null);
}, [filteredServers]);
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
}, [filteredServers, methods]);
const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
@@ -113,7 +129,7 @@ export default function NordModal({
async function login() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token: methods.getValues('token') });
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
@@ -126,7 +142,7 @@ export default function NordModal({
async function saveKey() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: methods.getValues('manualKey') });
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
@@ -144,14 +160,10 @@ export default function NordModal({
onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' });
setNordData(null);
setToken('');
setManualKey('');
methods.reset(EMPTY);
setCountries([]);
setCities([]);
setServers([]);
setCountryId(null);
setCityId(null);
setServerId(null);
}
} finally {
setLoading(false);
@@ -159,12 +171,11 @@ export default function NordModal({
}
async function fetchServers(newCountryId: number) {
setCountryId(newCountryId);
setLoading(true);
setServers([]);
setCities([]);
setServerId(null);
setCityId(null);
methods.setValue('serverId', null);
methods.setValue('cityId', null);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
if (!msg?.success || !msg.obj) return;
@@ -194,7 +205,8 @@ export default function NordModal({
}
function buildNordOutbound(): Record<string, unknown> | null {
const server = servers.find((s) => s.id === serverId);
const selectedServerId = methods.getValues('serverId');
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
@@ -244,6 +256,7 @@ export default function NordModal({
<>
{messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{nordData == null ? (
<Tabs
defaultActiveKey="token"
@@ -258,16 +271,12 @@ export default function NordModal({
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form.Item label={t('pages.xray.nord.accessToken')}>
<Input
value={token}
placeholder={t('pages.xray.nord.accessToken')}
onChange={(e) => setToken(e.target.value)}
/>
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
{t('login')}
</Button>
</Form.Item>
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
<Input placeholder={t('pages.xray.nord.accessToken')} />
</FormField>
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
{t('login')}
</Button>
</Form>
),
},
@@ -281,16 +290,12 @@ export default function NordModal({
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form.Item label={t('pages.xray.nord.privateKey')}>
<Input
value={manualKey}
placeholder={t('pages.xray.nord.privateKey')}
onChange={(e) => setManualKey(e.target.value)}
/>
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
{t('save')}
</Button>
</Form.Item>
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
<Input placeholder={t('pages.xray.nord.privateKey')} />
</FormField>
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
{t('save')}
</Button>
</Form>
),
},
@@ -320,35 +325,34 @@ export default function NordModal({
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
<Form.Item label={t('pages.xray.outbound.country')}>
<FormField
name="countryId"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => fetchServers(v as number)}
>
<Select
value={countryId ?? undefined}
showSearch={{ optionFilterProp: 'label' }}
onChange={(v) => fetchServers(v)}
options={countries.map((c) => ({
value: c.id,
label: `${c.name} (${c.code})`,
}))}
/>
</Form.Item>
</FormField>
{cities.length > 0 && (
<Form.Item label={t('pages.xray.outbound.city')}>
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
<Select
value={cityId}
showSearch={{ optionFilterProp: 'label' }}
onChange={setCityId}
options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
/>
</Form.Item>
</FormField>
)}
{filteredServers.length > 0 && (
<Form.Item label={t('pages.xray.outbound.server')}>
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
<Select
value={serverId}
showSearch={{ optionFilterProp: 'label' }}
onChange={setServerId}
options={filteredServers.map((s) => ({
value: s.id,
label: `${s.cityName} ${s.name} ${s.hostname}`,
@@ -364,7 +368,7 @@ export default function NordModal({
),
}))}
/>
</Form.Item>
</FormField>
)}
</Form>
@@ -392,6 +396,7 @@ export default function NordModal({
)}
</>
)}
</FormProvider>
</Modal>
</>
);
+51 -43
View File
@@ -12,8 +12,10 @@ import {
Tag,
} from 'antd';
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
import { FormField } from '@/components/form/rhf';
import './WarpModal.css';
interface WarpModalProps {
@@ -51,6 +53,13 @@ interface WarpConfig {
};
}
interface WarpFormValues {
warpPlus: string;
updateInterval: number;
}
const EMPTY: WarpFormValues = { warpPlus: '', updateInterval: 0 };
function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
const out: string[] = [];
if (addrs.v4) out.push(`${addrs.v4}/32`);
@@ -79,10 +88,10 @@ export default function WarpModal({
const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
const [warpPlus, setWarpPlus] = useState('');
const [updateInterval, setUpdateInterval] = useState<number>(0);
const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
const methods = useForm<WarpFormValues>({ defaultValues: EMPTY });
const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
const warpOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
@@ -132,12 +141,12 @@ export default function WarpModal({
}
const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
if (settingMsg?.success && settingMsg.obj) {
setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
}
} finally {
setLoading(false);
}
}, []);
}, [methods]);
useEffect(() => {
if (!open) return;
@@ -202,7 +211,7 @@ export default function WarpModal({
async function saveInterval() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: methods.getValues('updateInterval') });
if (msg?.success) {
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
}
@@ -212,15 +221,16 @@ export default function WarpModal({
}
async function updateLicense() {
if (warpPlus.length < 26) return;
const licenseValue = methods.getValues('warpPlus');
if (licenseValue.length < 26) return;
setLoading(true);
setLicenseError('');
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: licenseValue });
if (msg?.success && msg.obj) {
setWarpData(JSON.parse(msg.obj));
setWarpConfig(null);
setWarpPlus('');
methods.setValue('warpPlus', '');
} else {
setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
}
@@ -266,6 +276,7 @@ export default function WarpModal({
<>
{messageContextHolder}
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{!hasWarp ? (
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
{t('pages.xray.warp.createAccount')}
@@ -307,29 +318,26 @@ export default function WarpModal({
label: t('pages.xray.warp.licenseKeyLabel'),
children: (
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('pages.xray.warp.key')}>
<Input
value={warpPlus}
placeholder={t('pages.xray.warp.keyPlaceholder')}
onChange={(e) => {
setWarpPlus(e.target.value);
setLicenseError('');
}}
/>
<div className="license-actions mt-8">
<Button
type="primary"
disabled={warpPlus.length < 26}
loading={loading}
onClick={updateLicense}
>
{t('update')}
</Button>
{licenseError && (
<Alert title={licenseError} type="error" showIcon className="license-error" />
)}
</div>
</Form.Item>
<FormField
name="warpPlus"
label={t('pages.xray.warp.key')}
onAfterChange={() => setLicenseError('')}
>
<Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
</FormField>
<div className="license-actions mt-8">
<Button
type="primary"
disabled={warpPlusValue.length < 26}
loading={loading}
onClick={updateLicense}
>
{t('update')}
</Button>
{licenseError && (
<Alert title={licenseError} type="error" showIcon className="license-error" />
)}
</div>
</Form>
),
},
@@ -338,18 +346,17 @@ export default function WarpModal({
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
children: (
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
<Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
<Input
type="number"
min={0}
value={updateInterval}
onChange={(e) => setUpdateInterval(Number(e.target.value))}
/>
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
{t('save', 'Save')}
</Button>
</Form.Item>
<FormField
name="updateInterval"
label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}
transform={{ output: (v) => Number(v) }}
>
<Input type="number" min={0} />
</FormField>
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
{t('save', 'Save')}
</Button>
</Form>
),
},
@@ -431,6 +438,7 @@ export default function WarpModal({
)}
</>
)}
</FormProvider>
</Modal>
</>
);
+150 -159
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
@@ -36,9 +38,7 @@ interface RuleFormModalProps {
onConfirm: (rule: Record<string, unknown>) => void;
}
type FormState = RuleFormValues;
const initialForm = (): FormState => ({
const initialForm = (): RuleFormValues => ({
enabled: true,
domain: '',
ip: '',
@@ -73,7 +73,7 @@ export default function RuleFormModal({
onConfirm,
}: RuleFormModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(initialForm);
const methods = useForm<RuleFormValues>({ defaultValues: initialForm() });
const isEdit = rule != null;
const { data: inboundOptions } = useInboundOptions();
@@ -82,7 +82,7 @@ export default function RuleFormModal({
useEffect(() => {
if (!open) return;
if (rule) {
setForm({
methods.reset({
enabled: rule.enabled !== false,
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
@@ -99,15 +99,14 @@ export default function RuleFormModal({
balancerTag: rule.balancerTag || '',
});
} else {
setForm(initialForm());
methods.reset(initialForm());
}
}, [open, rule]);
}, [open, rule, methods]);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? [];
function submit() {
const validated = RuleFormSchema.safeParse(form);
const validated = RuleFormSchema.safeParse(methods.getValues());
if (!validated.success) return;
const v = validated.data;
const built: Record<string, unknown> = {
@@ -154,167 +153,159 @@ export default function RuleFormModal({
onOk={submit}
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('enable')}>
<Switch
checked={form.enabled}
onChange={(checked) => update('enabled', checked)}
disabled={isApiRule(rule ?? {})}
/>
</Form.Item>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<FormField name="enabled" label={t('enable')} valueProp="checked">
<Switch disabled={isApiRule(rule ?? {})} />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input value={form.sourceIP} onChange={(e) => update('sourceIP', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</Form.Item>
<FormField
name="sourceIP"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input value={form.sourcePort} onChange={(e) => update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<FormField
name="sourcePort"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<FormField
name="vlessRoute"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<Form.Item label={t('pages.inbounds.network')}>
<Select
value={form.network}
onChange={(v) => update('network', v)}
options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))}
/>
</Form.Item>
<FormField name="network" label={t('pages.inbounds.network')}>
<Select options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} />
</FormField>
<Form.Item label={t('pages.inbounds.protocol')}>
<Select
mode="multiple"
value={form.protocol}
onChange={(v) => update('protocol', v)}
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
/>
</Form.Item>
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
</FormField>
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
<Button size="small" aria-label={t('add')} icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{form.attrs.map((attr, idx) => (
<Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={attr[0]}
aria-label={t('pages.nodes.name')}
placeholder={t('pages.nodes.name')}
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
update('attrs', next);
}}
/>
<Input
value={attr[1]}
aria-label={t('pages.xray.ruleForm.value')}
placeholder={t('pages.xray.ruleForm.value')}
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
update('attrs', next);
}}
/>
<Button
aria-label={t('remove')}
icon={<MinusOutlined />}
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
/>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
<Button
size="small"
aria-label={t('add')}
icon={<PlusOutlined />}
onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
/>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{attrs.map((attr, idx) => (
<Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={attr[0]}
aria-label={t('pages.nodes.name')}
placeholder={t('pages.nodes.name')}
onChange={(e) => {
const next = attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
methods.setValue('attrs', next);
}}
/>
<Input
value={attr[1]}
aria-label={t('pages.xray.ruleForm.value')}
placeholder={t('pages.xray.ruleForm.value')}
onChange={(e) => {
const next = attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
methods.setValue('attrs', next);
}}
/>
<Button
aria-label={t('remove')}
icon={<MinusOutlined />}
onClick={() => methods.setValue('attrs', attrs.filter((_, i) => i !== idx))}
/>
</Space.Compact>
))}
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input value={form.ip} onChange={(e) => update('ip', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</Form.Item>
<FormField
name="ip"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input value={form.domain} onChange={(e) => update('domain', e.target.value)} placeholder="google.com, geosite:cn" />
</Form.Item>
<FormField
name="domain"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="google.com, geosite:cn" />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input value={form.user} onChange={(e) => update('user', e.target.value)} placeholder="email address" />
</Form.Item>
<FormField
name="user"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="email address" />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input value={form.port} onChange={(e) => update('port', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<FormField
name="port"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<Form.Item label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
value={form.inboundTag}
onChange={(v) => update('inboundTag', v)}
options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
/>
</Form.Item>
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
/>
</FormField>
<Form.Item label={t('pages.xray.ruleForm.outboundTag')}>
<Select
value={form.outboundTag}
onChange={(v) => update('outboundTag', v)}
options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
/>
</Form.Item>
<FormField name="outboundTag" label={t('pages.xray.ruleForm.outboundTag')}>
<Select options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
</FormField>
<Form.Item
label={
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Select
value={form.balancerTag}
onChange={(v) => update('balancerTag', v)}
options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
/>
</Form.Item>
</Form>
<FormField
name="balancerTag"
label={
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Select options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { httpRequest, setupHttp } from '@/api/http-init';
import { CSRF_TOKEN } from './msw/handlers';
import { server } from './msw/server';
const ORIGIN = 'http://localhost';
describe('httpRequest against the MSW-mocked network', () => {
beforeEach(() => {
vi.stubGlobal('document', { querySelector: () => null });
window.X_UI_BASE_PATH = ORIGIN;
setupHttp();
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.X_UI_BASE_PATH;
});
it('fetches a CSRF token, then refetches and retries once after a 403', async () => {
let posts = 0;
const sentTokens: (string | null)[] = [];
server.use(
http.get(`${ORIGIN}/csrf-token`, () => HttpResponse.json({ success: true, obj: CSRF_TOKEN })),
http.post(`${ORIGIN}/panel/api/test`, ({ request }) => {
posts += 1;
sentTokens.push(request.headers.get('X-CSRF-Token'));
if (posts === 1) return new HttpResponse(null, { status: 403 });
return HttpResponse.json({ success: true, obj: 'saved' });
}),
);
const res = await httpRequest('POST', '/panel/api/test', { hello: 'world' });
expect(posts).toBe(2);
expect(sentTokens).toEqual([CSRF_TOKEN, CSRF_TOKEN]);
expect(res.status).toBe(200);
expect(res.data).toEqual({ success: true, obj: 'saved' });
});
it('resolves a safe GET without requesting a CSRF token', async () => {
let csrfHits = 0;
server.use(
http.get(`${ORIGIN}/csrf-token`, () => {
csrfHits += 1;
return HttpResponse.json({ success: true, obj: CSRF_TOKEN });
}),
http.get(`${ORIGIN}/panel/api/status`, () => HttpResponse.json({ success: true, obj: { up: true } })),
);
const res = await httpRequest('GET', '/panel/api/status');
expect(csrfHits).toBe(0);
expect(res.status).toBe(200);
expect(res.data).toEqual({ success: true, obj: { up: true } });
});
});
+29 -27
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { Form, type FormInstance } from 'antd';
import { Form } from 'antd';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import {
GrpcForm,
@@ -17,61 +18,62 @@ import { renderWithProviders, fieldLabels } from './test-utils';
function FormHarness({
children,
initialValues,
defaultValues,
}: {
children: (form: FormInstance<InboundFormValues>) => ReactNode;
initialValues?: Record<string, unknown>;
children: ReactNode;
defaultValues?: Record<string, unknown>;
}) {
const [form] = Form.useForm<InboundFormValues>();
return <Form form={form} initialValues={initialValues}>{children(form)}</Form>;
const methods = useForm<InboundFormValues>({ defaultValues: defaultValues as never });
return (
<FormProvider {...methods}>
<Form>{children}</Form>
</FormProvider>
);
}
function renderInForm(
node: (form: FormInstance<InboundFormValues>) => ReactNode,
initialValues?: Record<string, unknown>,
) {
return renderWithProviders(<FormHarness initialValues={initialValues}>{node}</FormHarness>);
function renderInForm(node: ReactNode, defaultValues?: Record<string, unknown>) {
return renderWithProviders(<FormHarness defaultValues={defaultValues}>{node}</FormHarness>);
}
const noop = () => {};
describe('inbound transport forms', () => {
it('RawForm field structure is stable', () => {
renderInForm(() => <RawForm />);
renderInForm(<RawForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('WsForm field structure is stable', () => {
renderInForm(() => <WsForm />);
renderInForm(<WsForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('GrpcForm field structure is stable', () => {
renderInForm(() => <GrpcForm />);
renderInForm(<GrpcForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('KcpForm field structure is stable', () => {
renderInForm(() => <KcpForm />);
renderInForm(<KcpForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('HttpUpgradeForm field structure is stable', () => {
renderInForm(() => <HttpUpgradeForm />);
renderInForm(<HttpUpgradeForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('XhttpForm field structure is stable', () => {
renderInForm((form) => <XhttpForm form={form} />);
renderInForm(<XhttpForm />);
expect(fieldLabels()).toMatchSnapshot();
});
it('SockoptForm field structure is stable (server-side fields only)', () => {
// The inbound sockopt form shows only server/listening-side fields;
// outbound-only fields (dialerProxy, domainStrategy, interface,
// addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form.
/* The inbound sockopt form shows only server/listening-side fields;
outbound-only fields (dialerProxy, domainStrategy, interface,
addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form. */
renderInForm(
() => <SockoptForm toggleSockopt={noop} network="tcp" />,
<SockoptForm toggleSockopt={noop} network="tcp" />,
{ streamSettings: { sockopt: { mark: 0 } } },
);
expect(fieldLabels()).toMatchSnapshot();
@@ -80,7 +82,7 @@ describe('inbound transport forms', () => {
describe('inbound security forms', () => {
it('TlsForm field structure is stable', () => {
renderInForm(() => (
renderInForm(
<TlsForm
saving={false}
setCertFromPanel={noop}
@@ -89,13 +91,13 @@ describe('inbound security forms', () => {
pinFromRemote={noop}
getNewEchCert={noop}
clearEchCert={noop}
/>
));
/>,
);
expect(fieldLabels()).toMatchSnapshot();
});
it('RealityForm field structure is stable', () => {
renderInForm(() => (
renderInForm(
<RealityForm
saving={false}
scanning={false}
@@ -109,8 +111,8 @@ describe('inbound security forms', () => {
clearRealityKeypair={noop}
genMldsa65={noop}
clearMldsa65={noop}
/>
));
/>,
);
expect(fieldLabels()).toMatchSnapshot();
});
});
+7
View File
@@ -0,0 +1,7 @@
import { http, HttpResponse } from 'msw';
export const CSRF_TOKEN = 'test-csrf-token';
export const handlers = [
http.get('*/csrf-token', () => HttpResponse.json({ success: true, obj: CSRF_TOKEN })),
];
+5
View File
@@ -0,0 +1,5 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FormProvider } from 'react-hook-form';
import { Form, Input, InputNumber, Switch } from 'antd';
import { z } from 'zod';
import { FormField, useZodForm } from '@/components/form/rhf';
const GB = 1024 * 1024 * 1024;
const Schema = z.object({
email: z.string().min(1, 'email-required'),
enabled: z.boolean(),
slug: z.string(),
bytes: z.number(),
});
type Values = z.infer<typeof Schema>;
function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
const methods = useZodForm(Schema, {
defaultValues: { email: '', enabled: false, slug: '', bytes: 2 * GB },
});
return (
<FormProvider {...methods}>
<Form layout="vertical">
<FormField name="email" label="Email">
<Input aria-label="email" />
</FormField>
<FormField name="enabled" label="Enabled" valueProp="checked">
<Switch aria-label="enabled" />
</FormField>
<FormField name="slug" label="Slug" transform={{ output: (v) => String(v).toLowerCase() }}>
<Input aria-label="slug" />
</FormField>
<FormField
name="bytes"
label="Traffic"
transform={{
input: (v) => (typeof v === 'number' ? v / GB : v),
output: (v) => (typeof v === 'number' ? v * GB : 0),
}}
>
<InputNumber aria-label="bytes" />
</FormField>
<button type="button" onClick={methods.handleSubmit(onSubmit)}>Save</button>
</Form>
</FormProvider>
);
}
describe('FormField', () => {
it('submits values normalized from the Ant Design inputs', async () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('email'), { target: { value: 'a@b.com' } });
fireEvent.click(screen.getByLabelText('enabled'));
fireEvent.change(screen.getByLabelText('slug'), { target: { value: 'HELLO' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({
email: 'a@b.com',
enabled: true,
slug: 'hello',
bytes: 2 * GB,
});
});
it('applies the transform output before storing the value', async () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('email'), { target: { value: 'x' } });
const bytes = screen.getByLabelText('bytes');
fireEvent.change(bytes, { target: { value: '5' } });
fireEvent.blur(bytes);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].bytes).toBe(5 * GB);
});
it('surfaces a resolver validation error as help text and blocks submit', async () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
fireEvent.click(screen.getByText('Save'));
await screen.findByText('email-required');
expect(onSubmit).not.toHaveBeenCalled();
});
});
+7
View File
@@ -0,0 +1,7 @@
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './msw/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
-15
View File
@@ -1,15 +0,0 @@
import type { Rule } from 'antd/es/form';
import type { TFunction } from 'i18next';
import type { z } from 'zod';
export function antdRule<T extends z.ZodType>(schema: T, t: TFunction): Rule {
return {
validator: async (_rule, value) => {
const result = schema.safeParse(value);
if (result.success) return;
const issue = result.error.issues[0];
const key = issue?.message ?? 'validation.invalid';
throw new Error(t(key, { defaultValue: key }));
},
};
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
name: 'unit',
include: ['src/test/**/*.test.ts'],
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
setupFiles: ['./src/test/setup.ts', './src/test/setup.msw.ts'],
},
},
{