mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 13:46:08 +00:00
Frontend dev tooling (Husky, lint-staged, MSW, Storybook) + full React Hook Form migration (#5859)
* chore(frontend): add husky + lint-staged pre-commit gate Wire a local pre-commit gate that runs eslint --fix on staged frontend TypeScript via lint-staged. Because the only package.json lives in frontend/ while the git root is one level up, the prepare script installs husky hooks at frontend/.husky from the repo root (cd .. && husky frontend/.husky), and the pre-commit hook cd's into frontend/ before invoking lint-staged so node_modules resolves. * test(frontend): add MSW request mocking Add Mock Service Worker so tests can exercise the real http-init.ts request pipeline (CSRF acquisition, 403 refetch-and-retry, body parsing) instead of only stubbing HttpUtil. A node setupServer is started for the vitest unit project with onUnhandledRequest bypass so the existing HttpUtil spies and 55 component tests are untouched; the browser worker is copied to public/ for Storybook and dev use. * chore(frontend): add Storybook + component stories Set up Storybook 10 on the React-Vite builder (compatible with the pinned Vite 8.1.3 and React 19). The preview decorator mirrors the vitest component harness: an Ant Design ConfigProvider with a light/dark toolbar toggle and an en-US i18next instance. main.ts neutralizes the app vite config bits that do not belong in a component workshop (the three-entry rollup input, renderBuiltUrl, and the shared dist outDir) so build-storybook can never clobber internal/web/dist. Seeds stories across the presentational library (viz, ui, clients, feedback). build-storybook is a local tool and is not wired into the CI gate. * feat(frontend): add React Hook Form primitives Introduce the shared RHF layer that AntD inputs bind through, ahead of migrating the forms off Ant Design's Form store: - FormField wraps a Controller in an Ant Design Form.Item shell, reconciling the value/onChange shapes of Input, Switch, InputNumber, Select and friends via normalizeAntdOnChange, with input/output transforms and Zod-issue-key error messages resolved through t(). - useZodForm wires zodResolver (Zod 4) with the AntD-matching modes (validate on submit, then live) and shouldUnregister false so hidden and unmounted-tab fields keep their values. - rhfZodValidate covers the rare per-field rule sites. Covered by a FormField test exercising normalization, transforms, and resolver error surfacing. * refactor(frontend): migrate Pattern-B leaf forms to React Hook Form Move the controlled-useState leaf forms onto RHF via the FormField primitive, keeping Ant Design components and each form's exact submit behaviour (same safeParse, same toast on the first Zod issue, same payload building): - clients: ClientBulkAdjustModal, BulkAddToGroupModal, ClientBulkAddModal - xray: RuleFormModal, BalancerFormModal, WarpModal, NordModal Multi-control widgets that don't fit a single input (inbound dual select, subId regen, expiry branches, the balancer tag warning) stay as explicit Controller/setValue. Derived visibility now reads live values through useWatch. FormField gains a required prop so migrated fields keep their required-asterisk affordance. Settings tabs are intentionally excluded: they are control-panel components that live-patch a parent AllSetting via SettingListItem, not Ant Design Form submit-forms. * refactor(frontend): migrate LoginPage to React Hook Form Replace the Ant Design Form store + antdRule per-field validation with useForm + FormField. The AntD Form stays as the layout/submit wrapper, now driving methods.handleSubmit(onSubmit) via onFinish. Username and password validate through rhfZodValidate(LoginFormSchema.shape.*); the two-factor field keeps its conditional required rule (only registered when 2FA is enabled). Submit posts the same values to /login. * refactor(frontend): migrate ClientFormModal to React Hook Form Move the client add/edit form off controlled useState onto RHF while preserving exact submit behaviour (same ClientFormSchema / ClientCreateFormSchema safeParse, same toast, same payload + attach/ detach diff + external-links build). expiryDate is stored as an epoch number (never a Dayjs) to survive RHF's value cloning, converted at the DateTimePicker boundary. externalLinks uses useFieldArray with stable ids. inboundIds and the derived show*/ss2022 visibility read live via useWatch. Space.Compact button-group widgets stay manual Controllers so the joined borders keep working. * refactor(frontend): migrate Node and DNS modals to React Hook Form Both are self-contained Pattern-A forms (no shared fragments). Replace Form.useForm with useForm + FormProvider, Form.useWatch with useWatch, setFieldValue with setValue, and partial validateFields([...]) with methods.trigger([...]). Per-field antdRule becomes rhfZodValidate rules; the Node scheme->tlsVerify cascade moves to FormField onAfterChange; the DNS domains/expectIPs/unexpectIPs string arrays are driven by useWatch + setValue. Submit runs through handleSubmit on the modal OK button, preserving each form's exact validation, payload build, and save/onConfirm behaviour. * refactor(frontend): migrate HostFormModal to React Hook Form The host external-proxy editor's outer form moves to useForm + FormProvider. Security/tab visibility reads via useWatch; the three json-form editors (HostMuxForm/HostSockoptForm/HostFinalMaskForm) are bound as value/onChange black boxes through a Controller (their own internal forms are unchanged). remark/inboundId keep their validation via rhfZodValidate; submit runs through handleSubmit and builds the same payload (isDisabled = !enable) and save call. * refactor(frontend): migrate OutboundFormModal + fragments to React Hook Form Move the outbound form cluster off Ant Design's Form store onto RHF. The parent uses useForm + FormProvider with a watch() subscription for the protocol reseed cascade and setValue-based network/security/xmux cascades; the JSON<->Basic bridge and the formValuesToWirePayload submit are preserved exactly. Every outbound transport/protocol/security fragment now binds through FormField/useWatch via context. The shared config editors stay untouched and are bound through small value/onChange adapters (src/lib/xray/forms/fields: FinalMaskField, SniffingField, SockoptCustomField) via Controller; HeaderMapEditor binds directly. The host json-form wrappers that reuse the outbound MuxForm/ SockoptForm (HostMuxForm, HostSockoptForm, OutboundSubtreeJsonForm) move to a local RHF provider to match. Outbound render/link tests pass unchanged. * refactor(frontend): migrate InboundFormModal + fragments to React Hook Form Move the inbound add/edit form (the largest form in the panel) and its transport/protocol/security fragments off Ant Design's Form store onto RHF, mirroring the outbound migration. The parent uses useForm + FormProvider with a watch() subscription for the protocol reseed cascade (type==='change' guard so programmatic resets don't reseed) and setValue-based network/security cascades; useSecurityActions drives the TLS/Reality keypair + scan through setValue. Hidden pass-through Form.Items are dropped (their values ride in the reset object and survive via shouldUnregister:false), so getValues() still returns the settings.clients subtree untouched. accounts / certificates / tun lists use useFieldArray; the shared FinalMask/Sniffing/Sockopt editors bind through the value/onChange adapters. Submit keeps the manual InboundFormSchema.safeParse + formatInboundValidation toast + formValuesToWirePayload exactly. The golden link/full fixtures pass byte-for-byte, confirming identical wire output. inbound-form-blocks test harness rewritten from a Form.useForm harness to an RHF provider. * refactor(frontend): retire antdRule; document the RHF form pattern All forms now build on React Hook Form, so the AntD-Form Zod adapter antdRule (src/utils/zodForm.ts) has no remaining callers — remove it. Update frontend/CLAUDE.md: forms use useZodForm + FormField from components/form/rhf with zodResolver/rhfZodValidate validation; AntD <Form> is layout-only; the shared FinalMask/Sniffing/Sockopt editors stay AntD islands wrapped as value/onChange adapters bound via a Controller. * chore(frontend): cover esbuild in the allowScripts allowlist esbuild (pulled in transitively by Vite/Vitest/Storybook) ships a postinstall that npm's allow-scripts flags as uncovered on every install. Its platform binary is delivered through the @esbuild/<platform> optionalDependencies, so the postinstall isn't needed here; deny it like the other entries to silence the warning. * fix(frontend): restore label layout in Sniffing/FinalMask field adapters The value/onChange adapters that wrap the shared SniffingFields and FinalMaskForm editors put them in their own isolated AntD Form, but that Form was missing the label layout the fields used to inherit from the inbound/outbound parent form. Their labels rendered full-width instead of the compact right-aligned column, so the Sniffing tab and the TCP Masks / QUIC Params sections looked broken. Give both adapter forms the same colon=false, labelCol/wrapperCol span 8/14, labelWrap layout. * ci: add least-privilege permissions to Docs CI workflow The docs-ci workflow had no explicit permissions block, so it inherited the repository default for GITHUB_TOKEN. The build job only checks out and builds the docs, so restrict it to contents: read, resolving the CodeQL actions/missing-workflow-permissions alert.
This commit is contained in:
@@ -11,6 +11,9 @@ on:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/docs-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs
|
||||
|
||||
@@ -3,3 +3,4 @@ node_modules/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
storybook-static/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
cd frontend && npx lint-staged
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+3469
File diff suppressed because it is too large
Load Diff
+24
-2
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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 }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user